diff --git a/CMakeLists.txt b/CMakeLists.txt index b744a241a..717cf3cf3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -397,6 +397,9 @@ add_library(moqx_core STATIC src/relay/PublisherCrossExecFilter.cpp src/relay/SubscriberCrossExecFilter.cpp src/logging/LogSetup.cpp + src/tls/CertDirScanner.cpp + src/tls/SniCertManager.cpp + src/tls/FizzContextBuilder.cpp ) target_include_directories(moqx_core diff --git a/RUNNING.md b/RUNNING.md index 688d397d4..42599356b 100644 --- a/RUNNING.md +++ b/RUNNING.md @@ -154,7 +154,7 @@ docker compose up -d | `MOQX_VERBOSE` | `0` | VLOG level (0-4) | | `MOQX_LOG_PORT` | `9999` | Dozzle log viewer port (localhost only) | | `GLOG_vmodule` | -- | Per-module verbose level (passed through) | -| `MOQX_INSECURE` | `false` | Use built-in dev cert | +| `MOQX_INSECURE` | `false` | Use built-in dev cert; the entrypoint exits when set with `MOQX_CERT`/`MOQX_KEY` | The entrypoint maps `MOQX_LOG_LEVEL` and `MOQX_VERBOSE` to their `GLOG_*` equivalents and forces `GLOG_logtostderr=1`. diff --git a/config.example.yaml b/config.example.yaml index 5329ccd6f..7b00a295f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -8,8 +8,8 @@ listeners: address: "::" # Bind address (default: "::" = all interfaces) port: 9668 # Listen port (1-65535) tls: - # cert_file: /path/to/cert.pem # Required when insecure: false - # key_file: /path/to/key.pem # Required when insecure: false + # cert_file: /path/to/cert.pem # Required when insecure: false (with key_file) + # key_file: /path/to/key.pem # Required when insecure: false (with cert_file) # --- PKCS#12 bundle (alternative to cert_file/key_file) --- # A single .p12/.pfx carrying the cert chain + private key. Decrypted in # memory; the key is never written to disk. Mutually exclusive with @@ -18,7 +18,18 @@ listeners: # pkcs12_password_file: /path/to/password.txt # Preferred: keep the secret off the YAML # pkcs12_password_env: MOQX_PKCS12_PASSWORD # Cloud-vault friendly: read the password from this env var # pkcs12_password: "inline-secret" # Discouraged: persists in the config at rest - insecure: true # Skip TLS for local development + # --- Multi-cert / SNI (fizz TLS stack: mvfst + proxygen_qmux) --- + # Serve per-hostname certs from a directory of .pem + .key pairs, + # selected by SNI (identities: DNS SANs, or CN when there are no DNS SANs). + # cert_file/key_file or pkcs12_file, if also set, serve connections whose SNI + # is absent or matches nothing. See docs/config.md. + # fizz: + # cert_dir: /path/to/certs + # cert_reload_interval_s: 60 # background rescan; 0 = scan once at startup + # ticket_seeds_file: /path/to/ticket-seeds # hex seeds, one per line; share across relays so resumption survives restarts + # --- Development-only mode --- + # insecure is rejected alongside cert_file/key_file/pkcs12_file/fizz. + insecure: true # Serve the compiled-in dev cert; no real credentials (development only) endpoint: "/moq-relay" # WebTransport endpoint path # moqt_versions: [14, 16] # MOQT draft versions (empty = default 14,16) # quic: # Per-listener QUIC overrides (optional; inherits listener_defaults.quic) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 154fd2391..25f373010 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -101,7 +101,21 @@ export MOQX_PORT="${MOQX_PORT:-4433}" export MOQX_ADMIN_PORT="${MOQX_ADMIN_PORT:-8000}" export MOQX_CERT="${MOQX_CERT:-}" export MOQX_KEY="${MOQX_KEY:-}" -export MOQX_INSECURE="${MOQX_INSECURE:-false}" +# Canonicalized before use: YAML accepts yes/on/1 as true, but the shell tests +# below and in the picoquic block compare against the literal "true". +case "$(printf %s "${MOQX_INSECURE:-false}" | tr '[:upper:]' '[:lower:]')" in + true|yes|on|1) MOQX_INSECURE=true ;; + false|no|off|0) MOQX_INSECURE=false ;; + *) echo "invalid boolean for MOQX_INSECURE (want true/false)" >&2; exit 2 ;; +esac +export MOQX_INSECURE +# Both defaulted to empty above, so a value here is one the operator set. +# Clearing it would serve the built-in dev cert instead, which is the silent +# swap the config schema exists to refuse. +if [ "$MOQX_INSECURE" = "true" ] && { [ -n "$MOQX_CERT" ] || [ -n "$MOQX_KEY" ]; }; then + echo "MOQX_INSECURE=true is mutually exclusive with MOQX_CERT/MOQX_KEY" >&2 + exit 2 +fi export MOQX_ENDPOINT="${MOQX_ENDPOINT:-/moq-relay}" export MOQX_MOQT_VERSIONS="${MOQX_MOQT_VERSIONS:-[16, 14, 18]}" export MOQX_MAX_TRACKS="${MOQX_MAX_TRACKS:-1000}" diff --git a/docs/config.md b/docs/config.md index a486c0d38..7d7cf83af 100644 --- a/docs/config.md +++ b/docs/config.md @@ -61,14 +61,80 @@ listeners: tls: cert_file: /etc/moqx/cert.pem key_file: /etc/moqx/key.pem + # fizz: # optional; mvfst/proxygen_qmux only + # cert_dir: /etc/moqx/certs + # cert_reload_interval_s: 60 endpoint: /moq-relay quic_stack: mvfst # optional; default mvfst moqt_versions: [] # optional; empty = default [14, 16] quic: { ... } # optional; overrides listener_defaults.quic ``` -**TLS:** For development only, `tls: {insecure: true}` skips certificate -verification. This is incompatible with `quic_stack: picoquic`. +**TLS:** `tls: {insecure: true}` is for development only. + +- `insecure: true` serves a compiled-in certificate and does not request a + client certificate. +- `insecure: true` alongside `cert_file`, `key_file`, `pkcs12_file`, or + `fizz.cert_dir` is rejected at config load. +- `insecure: true` alongside `fizz.ticket_seeds_file` or + `fizz.cert_reload_interval_s` warns: neither reaches the compiled-in + certificate path. +- `quic_stack: picoquic` rejects `insecure: true`. + +**Multi-certificate / SNI (`tls.fizz`):** options for the fizz TLS stack +(`quic_stack: mvfst` and `proxygen_qmux`). `picoquic` rejects any option set in +the block; an empty `fizz: {}` is accepted. + +| Field | Default | Notes | +|---|---|---| +| `cert_dir` | unset | Directory of certificate pairs, `.pem` + `.key` (arbitrary basenames, non-recursive). The certificate served is selected by the client's SNI. | +| `cert_reload_interval_s` | 60 | Seconds between background rescans of `cert_dir`. `0` = scan once at startup, never rescan. Warns when set without `cert_dir`. | +| `ticket_seeds_file` | unset | Session-ticket seeds: one hex-encoded seed (≥64 hex chars) per line, `#` starts a comment. Read once at startup. | + +- Identities come from each certificate's DNS SANs, or from its CN when the + certificate carries no DNS SANs. Other SAN types (IP, email) are ignored. +- Wildcards match one label: `*.example.com` covers `a.example.com`, not + `b.a.example.com`. +- Nested wildcards are rejected. +- Certificates and keys are read up front: at startup, and on the rescan + thread afterwards. No handshake ever waits on disk. +- Deferring the key read to first use is not an option: fizz's cert-selection + hook is synchronous, so the read would land on the connection's IO thread + and a slow `cert_dir` mount would stall every handshake sharing that thread. +- `cert_file`/`key_file` (or `pkcs12_file`), if also set, become the fallback + certificate, serving connections with no SNI or no matching identity. +- With `cert_dir` and no fallback certificate, a connection with no SNI or no + matching identity fails the handshake. +- A PKCS#12 fallback is decrypted once at config load and is never refreshed. +- Rescans pick up added, removed, and changed pairs, and refresh a changed + `cert_file`/`key_file` fallback. +- Startup is strict: an orphan `.pem`/`.key`, an unparsable certificate, a + duplicate identity, or a key that fails to load aborts the relay. +- A rescan is not strict: its errors are logged, and every certificate already + loaded keeps serving. +- Removing either file of a pair retires it on the next rescan; a lone `.pem` + or `.key` left behind is not served. +- A changed pair that fails to load keeps serving its previous version until a + later rescan succeeds. +- A newly added pair that fails to load is dropped, and its identities fall to + the fallback certificate or to a handshake miss. +- A new pair claiming an identity another pair already serves is dropped with + a warning, and the incumbent keeps that identity until its own files are + removed. At startup there is no incumbent: sorted filename order decides, + and the duplicate is fatal. + +**`ticket_seeds_file`:** a moqx format, not one shared with nginx or HAProxy. + +- The first seed encrypts new session tickets. +- Every listed seed still decrypts, so rotate by prepending a new seed. +- Generate a seed with `openssl rand -hex 32`. +- Point every relay instance at the same file so resumption survives restarts + and works across relays. +- The file is read once at startup, so a rotation takes effect on restart. +- Without the option each process uses a random seed, and resumption dies with + the process. +- An empty file, a comments-only file, a non-hex line, or a seed under 32 + bytes is a config-load error. **moqt_versions:**: Currently supports 14 and 16. @@ -530,7 +596,8 @@ or a restart. | Lifecycle | When changes apply | Examples | |---|---|---| -| **Static** | Process restart required | listeners, admin, relay_id, TLS certificates, QUIC stack | +| **Static** | Process restart required | listeners, admin, relay_id, `cert_file`/`key_file`/PKCS#12 certificates, QUIC stack | +| **Automatic** | Background rescan, no reload needed | certificates under `tls.fizz.cert_dir` (see `cert_reload_interval_s`) | | **Reload:NewConn** | New connections/sessions only | service match rules, upstream URL | | **Reload:NewSub** | New subscriptions/tracks only | cache settings | | **Reload:Immediate** | All connections immediately | (reserved for future fields) | diff --git a/scripts/moqx-run.sh b/scripts/moqx-run.sh index d7920b8bc..eb4de3df1 100755 --- a/scripts/moqx-run.sh +++ b/scripts/moqx-run.sh @@ -279,6 +279,12 @@ else fi export MOQX_INSECURE +# The config schema rejects insecure alongside a cert source, so drop the paths +# here rather than rendering a config the relay refuses to load. +if [[ "$MOQX_INSECURE" == true ]]; then + export MOQX_CERT="" MOQX_KEY="" +fi + # ── Resolve placeholders into a temp config ─────────────────────────────── # Fixed path by default; override (e.g. per perf run, to avoid concurrent # clobber) with MOQX_RESOLVED_CONFIG. diff --git a/src/MoqxPicoRelayServer.cpp b/src/MoqxPicoRelayServer.cpp index 41812a3c5..d33d0c95c 100644 --- a/src/MoqxPicoRelayServer.cpp +++ b/src/MoqxPicoRelayServer.cpp @@ -42,7 +42,7 @@ std::string resolveCert(const config::ListenerConfig& cfg) { if constexpr (std::is_same_v) { return ""; } else { - return tls.certFile; + return tls.tls.certFile; } }, cfg.tlsMode @@ -56,7 +56,7 @@ std::string resolveKey(const config::ListenerConfig& cfg) { if constexpr (std::is_same_v) { return ""; } else { - return tls.keyFile; + return tls.tls.keyFile; } }, cfg.tlsMode diff --git a/src/MoqxQmuxRelayServer.cpp b/src/MoqxQmuxRelayServer.cpp index 46e52a854..1f2aa9854 100644 --- a/src/MoqxQmuxRelayServer.cpp +++ b/src/MoqxQmuxRelayServer.cpp @@ -7,9 +7,9 @@ #include "MoqxQmuxRelayServer.h" #include "stats/EventBaseStatsCollector.h" +#include "tls/FizzContextBuilder.h" #include #include -#include #include #include @@ -26,32 +26,6 @@ std::vector buildQmuxAlpns(const std::string& versions) { return getMoqtProtocols(versions, /*useStandard=*/true); } -std::shared_ptr -buildFizzContext(const config::ListenerConfig& cfg) { - auto alpns = buildQmuxAlpns(cfg.moqtVersions); - return std::visit( - [&alpns](const auto& tls) -> std::shared_ptr { - using T = std::decay_t; - if constexpr (std::is_same_v) { - return quic::samples::createFizzServerContextWithInsecureDefault( - alpns, - fizz::server::ClientAuthMode::None, - "", - "" - ); - } else { - return quic::samples::createFizzServerContext( - alpns, - fizz::server::ClientAuthMode::Optional, - tls.certFile, - tls.keyFile - ); - } - }, - cfg.tlsMode - ); -} - // Translate the QUIC flow-control / idle-timeout knobs into the QMUX transport // params advertised to the peer. mvfst-only tunables don't apply over TCP. MoQQmuxServer::Config buildQmuxConfig(const config::QuicConfig& quic) { @@ -78,7 +52,10 @@ MoqxQmuxRelayServer::MoqxQmuxRelayServer( ) : MoQQmuxServer( listenerCfg.endpoint, - buildFizzContext(listenerCfg), + tls::buildFizzServerContext( + listenerCfg.tlsMode, + {.alpns = buildQmuxAlpns(listenerCfg.moqtVersions)} + ), buildQmuxConfig(listenerCfg.quic) ), listenerCfg_(listenerCfg), context_(std::move(context)), ioExecutor_(ioExecutor) { diff --git a/src/MoqxRelayServer.cpp b/src/MoqxRelayServer.cpp index b798ba7b3..47912b015 100644 --- a/src/MoqxRelayServer.cpp +++ b/src/MoqxRelayServer.cpp @@ -7,24 +7,15 @@ #include "MoqxRelayServer.h" #include "stats/EventBaseStatsCollector.h" #include "stats/QuicStatsCollector.h" +#include "tls/FizzContextBuilder.h" #include #include #include -#include -#include -#include -#include -#include -#include -#include -#include #include #include #include -#include - using namespace moxygen; namespace openmoq::moqx { @@ -38,97 +29,6 @@ std::vector buildAlpns(const std::string& versions) { return alpns; } -// Build a FizzServerContext from in-memory PEM buffers so a PKCS#12-derived key -// never touches disk. Mirrors quic::samples::createFizzServerContextImpl (the -// path-based sample helper) to match the PEM-file path's TLS behavior. -// TODO(#482): replace with a buffer-based helper in the moxygen fork. -std::shared_ptr buildFizzContextFromMaterial( - const std::vector& alpns, - fizz::server::ClientAuthMode clientAuth, - const std::string& certChainPem, - const std::string& keyPem -) { - std::unique_ptr cert; - fizz::Error err; - FIZZ_THROW_ON_ERROR(fizz::openssl::CertUtils::makeSelfCert(cert, err, certChainPem, keyPem), err); - auto certManager = std::make_shared(); - certManager->addCertAndSetDefault(std::move(cert)); - - auto ctx = std::make_shared(); - ctx->setCertManager(certManager); - auto ticketCipher = std::make_shared>>( - ctx->getFactoryPtr(), - std::move(certManager) - ); - std::array ticketSeed; - folly::Random::secureRandom(ticketSeed.data(), ticketSeed.size()); - ticketCipher->setTicketSecrets({{folly::range(ticketSeed)}}); - ctx->setTicketCipher(ticketCipher); - ctx->setClientAuthMode(clientAuth); - ctx->setSupportedAlpns(alpns); - ctx->setAlpnMode(fizz::server::AlpnMode::Required); - ctx->setSendNewSessionTicket(true); - ctx->setEarlyDataFbOnly(false); - ctx->setVersionFallbackEnabled(false); - - fizz::server::ClockSkewTolerance tolerance; - tolerance.before = std::chrono::minutes(-5); - tolerance.after = std::chrono::minutes(5); - std::shared_ptr replayCache = - std::make_shared(); - ctx->setEarlyDataSettings(true, tolerance, std::move(replayCache)); - return ctx; -} - -std::shared_ptr -buildFizzContext(const config::ListenerConfig& cfg) { - auto alpns = buildAlpns(cfg.moqtVersions); - return std::visit( - [&alpns](const auto& tls) -> std::shared_ptr { - using T = std::decay_t; - if constexpr (std::is_same_v) { - return quic::samples::createFizzServerContextWithInsecureDefault( - alpns, - fizz::server::ClientAuthMode::None, - "", - "" - ); - } else { - // PKCS#12 source: build the context from the in-memory PEM material - // (cert/key never written to disk). - if (tls.material.has_value()) { - return buildFizzContextFromMaterial( - alpns, - fizz::server::ClientAuthMode::Optional, - tls.material->certChainPem, - tls.material->keyPem - ); - } - // createFizzServerContext throws (deep in fizz) when the cert/key - // can't be read or contain no certificate. Enrich the message with - // the offending paths so the caller can report it cleanly instead of - // letting an opaque "no certificates read" escape to std::terminate. - try { - return quic::samples::createFizzServerContext( - alpns, - fizz::server::ClientAuthMode::Optional, - tls.certFile, - tls.keyFile - ); - } catch (const std::exception& e) { - throw std::runtime_error( - "failed to load TLS certificate/key (cert='" + tls.certFile + "', key='" + - tls.keyFile + "'): " + e.what() + - " - check the paths exist and are readable by this process" - ); - } - } - }, - cfg.tlsMode - ); -} - quic::TransportSettings buildTransportSettings(const config::QuicConfig& quic, const config::MvfstConfig& mvfst) { // Start with MoQServer's optimized defaults, then apply config overrides. @@ -211,7 +111,10 @@ MoqxRelayServer::MoqxRelayServer( folly::IOThreadPoolExecutor* ioExecutor ) : MoQServer( - buildFizzContext(listenerCfg), + tls::buildFizzServerContext( + listenerCfg.tlsMode, + {.alpns = buildAlpns(listenerCfg.moqtVersions)} + ), listenerCfg.endpoint, MoQServer::Options{ .transportSettings = buildTransportSettings(listenerCfg.quic, listenerCfg.mvfst), diff --git a/src/config/Config.h b/src/config/Config.h index 1da4bfd07..6ce4ece29 100644 --- a/src/config/Config.h +++ b/src/config/Config.h @@ -33,6 +33,14 @@ struct TlsMaterial { std::string keyPem; // unencrypted private key PEM }; +// SNI multi-cert source: a directory of .pem/.key pairs, scanned +// for identities (DNS SANs; the CN only when a cert has none) and loaded up +// front on the scanning thread. Fizz stack only. +struct CertDirConfig { + std::string dir; + std::chrono::seconds reloadInterval{60}; // 0 = scan once at startup, never rescan +}; + struct TlsConfig { std::string certFile; std::string keyFile; @@ -43,9 +51,21 @@ struct TlsConfig { std::optional material; }; +// Listener TLS: the shared TLS source plus fizz-stack-only options. +struct ListenerTlsConfig { + TlsConfig tls; + // When set, certs are served by SNI from this directory; tls.certFile/keyFile + // or tls.material, if also present, act as the fallback cert. + std::optional certDir; + // Decoded session-ticket seeds, raw bytes, each at least 32 bytes; the first + // encrypts new tickets, all decrypt. Secret material: never log or serialize. + // Empty = a random per-process seed (resumption dies with the process). + std::vector ticketSeeds; +}; + struct Insecure {}; -using TlsMode = std::variant; +using TlsMode = std::variant; struct CacheConfig { size_t maxCachedTracks; // 0 when cache disabled diff --git a/src/config/ConfigResolver.cpp b/src/config/ConfigResolver.cpp index 9a2309efe..620873fe3 100644 --- a/src/config/ConfigResolver.cpp +++ b/src/config/ConfigResolver.cpp @@ -109,6 +109,23 @@ void validatePkcs12PasswordExclusivity( } } +bool hasCertDir(const ParsedListenerTlsConfig& tls) { + const auto& fizz = tls.fizz.value(); + return fizz.has_value() && fizz->cert_dir.value().has_value() && !fizz->cert_dir.value()->empty(); +} + +// At least one fizz option actually set; a bare `fizz: {}` block is inert and +// must not trip the rejections/warnings that gate fizz-only behavior. +bool hasFizzOptions(const ParsedListenerTlsConfig& tls) { + const auto& fizz = tls.fizz.value(); + if (!fizz.has_value()) { + return false; + } + const auto& seeds = fizz->ticket_seeds_file.value(); + return hasCertDir(tls) || fizz->cert_reload_interval_s.value().has_value() || + (seeds.has_value() && !seeds->empty()); +} + void validateListenerTlsConfig( const ParsedListenerTlsConfig& tls, std::string_view context, @@ -118,16 +135,70 @@ void validateListenerTlsConfig( bool hasCert = tls.cert_file.value().has_value() && !tls.cert_file.value()->empty(); bool hasKey = tls.key_file.value().has_value() && !tls.key_file.value()->empty(); bool hasPkcs12 = tls.pkcs12_file.value().has_value() && !tls.pkcs12_file.value()->empty(); + bool hasDir = hasCertDir(tls); if (tls.insecure.value()) { - if (hasCert || hasKey || hasPkcs12) { - warnings.push_back( - std::string(context) + ": cert_file/key_file/pkcs12_file are ignored when insecure=true" + // Rejected rather than ignored: silently dropping real credentials in + // favor of the compiled-in dev cert is the kind of mistake that only + // shows up in production traffic. + std::vector certSources; + if (hasCert) { + certSources.emplace_back("cert_file"); + } + if (hasKey) { + certSources.emplace_back("key_file"); + } + if (hasPkcs12) { + certSources.emplace_back("pkcs12_file"); + } + if (hasDir) { + certSources.emplace_back("fizz.cert_dir"); + } + if (!certSources.empty()) { + errors.push_back( + std::string(context) + ": insecure=true is mutually exclusive with " + + folly::join("/", certSources) ); } + // The rest of the fizz block costs only resumption sharing and reloads, + // so it warns instead: insecure serves the compiled-in cert from the + // sample context, which no fizz option reaches (see FizzContextBuilder.h). + if (tls.fizz.value().has_value()) { + const auto& fizz = *tls.fizz.value(); + const auto& seeds = fizz.ticket_seeds_file.value(); + if (seeds.has_value() && !seeds->empty()) { + warnings.push_back( + std::string(context) + ": fizz.ticket_seeds_file has no effect with insecure=true" + ); + } + if (fizz.cert_reload_interval_s.value().has_value()) { + warnings.push_back( + std::string(context) + ": fizz.cert_reload_interval_s has no effect with insecure=true" + ); + } + } return; } + if (!hasDir && tls.fizz.value().has_value() && + tls.fizz.value()->cert_reload_interval_s.value().has_value()) { + warnings.push_back( + std::string(context) + ": fizz.cert_reload_interval_s has no effect without fizz.cert_dir" + ); + } + + // Existence check only; the full directory scan runs at server construction. + if (hasDir) { + const auto& dir = *tls.fizz.value()->cert_dir.value(); + std::error_code ec; + if (!std::filesystem::is_directory(dir, ec)) { + errors.push_back( + std::string(context) + ": fizz.cert_dir '" + dir + + "' does not exist or is not a directory" + ); + } + } + if (hasPkcs12) { if (hasCert || hasKey) { errors.push_back( @@ -135,10 +206,15 @@ void validateListenerTlsConfig( ); } validatePkcs12PasswordExclusivity(tls, context, errors); - } else if (!hasCert || !hasKey) { + } else if (hasCert != hasKey) { + // A half-configured fallback pair must error even when cert_dir already + // satisfies the required-source check. + errors.push_back(std::string(context) + ": cert_file and key_file must be set together"); + } else if (!hasDir && !hasCert) { + // hasCert == hasKey here (the != case errored above). errors.push_back( - std::string(context) + - ": cert_file and key_file (or pkcs12_file) are required when insecure=false" + std::string(context) + ": cert_file and key_file (or pkcs12_file, or fizz.cert_dir) are " + "required when insecure=false" ); } } @@ -157,14 +233,29 @@ void validateAdminTlsConfig(const ParsedAdminTlsConfig& tls, std::vector material) { +ListenerTlsConfig resolveTlsConfig( + const ParsedListenerTlsConfig& tls, + std::optional material, + std::vector ticketSeeds +) { const bool hasMaterial = material.has_value(); - return TlsConfig{ - .certFile = hasMaterial ? std::string{} : tls.cert_file.value().value_or(""), - .keyFile = hasMaterial ? std::string{} : tls.key_file.value().value_or(""), - .alpn = {}, - .material = std::move(material), + std::optional certDir; + if (hasCertDir(tls)) { + certDir = CertDirConfig{.dir = *tls.fizz.value()->cert_dir.value()}; + if (const auto& interval = tls.fizz.value()->cert_reload_interval_s.value()) { + certDir->reloadInterval = std::chrono::seconds(*interval); + } + } + return ListenerTlsConfig{ + .tls = + TlsConfig{ + .certFile = hasMaterial ? std::string{} : tls.cert_file.value().value_or(""), + .keyFile = hasMaterial ? std::string{} : tls.key_file.value().value_or(""), + .alpn = {}, + .material = std::move(material), + }, + .certDir = std::move(certDir), + .ticketSeeds = std::move(ticketSeeds), }; } @@ -222,6 +313,74 @@ folly::Expected resolvePkcs12Password( return folly::makeExpected(std::string{}); } +// Read and decode a ticket_seeds_file: one hex seed per line, '#' comments. +// Fizz rejects the whole set if any seed is under 32 bytes, which is why the +// length is validated per line here. +folly::Expected, std::string> resolveTicketSeeds(const std::string& path) { + std::string contents; + if (!folly::readFile(path.c_str(), contents)) { + return folly::makeUnexpected("failed to read ticket_seeds_file '" + path + "'"); + } + + auto parse = [&]() -> folly::Expected, std::string> { + std::vector seeds; + std::vector lines; + folly::split('\n', contents, lines); + for (size_t i = 0; i < lines.size(); ++i) { + auto line = lines[i]; + if (auto hash = line.find('#'); hash != folly::StringPiece::npos) { + line = line.subpiece(0, hash); + } + auto trimmed = folly::trimWhitespace(line); + if (trimmed.empty()) { + continue; + } + std::string decoded; + if (!folly::unhexlify(trimmed, decoded)) { + return folly::makeUnexpected( + "ticket_seeds_file '" + path + "' line " + std::to_string(i + 1) + + ": not a hex-encoded seed" + ); + } + if (decoded.size() < 32) { + return folly::makeUnexpected( + "ticket_seeds_file '" + path + "' line " + std::to_string(i + 1) + + ": seed must be at least 32 bytes (64 hex characters)" + ); + } + seeds.push_back(std::move(decoded)); + } + if (seeds.empty()) { + return folly::makeUnexpected("ticket_seeds_file '" + path + "' contains no seeds"); + } + return folly::makeExpected(std::move(seeds)); + }; + + auto result = parse(); + OPENSSL_cleanse(contents.data(), contents.size()); + return result; +} + +// Ticket seeds for a listener, from fizz.ticket_seeds_file. Empty when the +// option is absent; on failure, pushes a context-prefixed error. +std::vector resolveListenerTicketSeeds( + const ParsedListenerTlsConfig& tls, + const std::string& context, + std::vector& errors +) { + const auto& fizz = tls.fizz.value(); + if (!fizz.has_value() || !fizz->ticket_seeds_file.value().has_value() || + fizz->ticket_seeds_file.value()->empty()) { + return {}; + } + auto seeds = resolveTicketSeeds(*fizz->ticket_seeds_file.value()); + if (seeds.hasError()) { + errors.push_back(context + ": " + seeds.error()); + return {}; + } + return std::move(*seeds); +} + // Transcode the configured PKCS#12 bundle into in-memory PEM material. Returns // nullopt when no pkcs12_file is set. On any failure, pushes a descriptive error // (prefixed with context) and returns nullopt. The resolved password copy is @@ -387,6 +546,12 @@ void validateListener( "': quic_stack \"picoquic\" does not support pkcs12_file yet; use cert_file/key_file" ); } + if (stackOpt.value_or(kStackMvfst) == kStackPicoquic && hasFizzOptions(listener.tls.value())) { + errors.push_back( + "Listener '" + listener.name.value() + + "': quic_stack \"picoquic\" does not support tls.fizz options; use cert_file/key_file" + ); + } } // --- Admin validation --- @@ -1075,7 +1240,8 @@ ListenerConfig resolveListener( const ParsedListenerConfig& listener, const QuicConfig& quic, const MvfstConfig& mvfst, - std::optional material + std::optional material, + std::vector ticketSeeds ) { const auto& sock = listener.udp.value().socket.value(); const auto& tls = listener.tls.value(); @@ -1084,7 +1250,7 @@ ListenerConfig resolveListener( if (tls.insecure.value()) { tlsMode = Insecure{}; } else { - tlsMode = resolveTlsConfig(tls, std::move(material)); + tlsMode = resolveTlsConfig(tls, std::move(material), std::move(ticketSeeds)); } const auto& stackStr = listener.quic_stack.value().value_or(kStackMvfst); @@ -1176,13 +1342,16 @@ folly::Expected resolveConfig(const ParsedConfig& c std::vector mergedQuicConfigs; std::vector mergedMvfstConfigs; std::vector> listenerTlsMaterials; + std::vector> listenerTicketSeeds; { std::unordered_set listenerAddrs; for (const auto& listener : config.listeners.value()) { validateListener(listener, errors, warnings); - // Transcode any PKCS#12 bundle now (skipped for insecure listeners) so a - // bad password/file surfaces as a load-time config error. + // Transcode any PKCS#12 bundle and read any ticket-seeds file now + // (skipped for insecure listeners) so a bad password/file surfaces as a + // load-time config error. std::optional material; + std::vector ticketSeeds; if (!listener.tls.value().insecure.value()) { material = resolvePkcs12Material( listener.tls.value(), @@ -1190,8 +1359,14 @@ folly::Expected resolveConfig(const ParsedConfig& c errors, warnings ); + ticketSeeds = resolveListenerTicketSeeds( + listener.tls.value(), + "Listener '" + listener.name.value() + "'", + errors + ); } listenerTlsMaterials.push_back(std::move(material)); + listenerTicketSeeds.push_back(std::move(ticketSeeds)); auto addr = listener.udp.value().socket.value().address.value() + ":" + std::to_string(listener.udp.value().socket.value().port.value()); if (!listenerAddrs.insert(addr).second) { @@ -1427,7 +1602,8 @@ folly::Expected resolveConfig(const ParsedConfig& c listeners[i], mergedQuicConfigs[i], mergedMvfstConfigs[i], - std::move(listenerTlsMaterials[i]) + std::move(listenerTlsMaterials[i]), + std::move(listenerTicketSeeds[i]) )); } return v; diff --git a/src/config/ConfigSerializer.h b/src/config/ConfigSerializer.h index 7c04312e4..b0df9621c 100644 --- a/src/config/ConfigSerializer.h +++ b/src/config/ConfigSerializer.h @@ -86,8 +86,23 @@ inline void serializeTls(ConfigSink& s, const TlsConfig& tls) { inline void serializeListenerTls(ConfigSink& s, const TlsMode& mode) { s.beginObject("tls"); - if (const auto* tls = std::get_if(&mode)) { - serializeTls(s, *tls); + if (const auto* tls = std::get_if(&mode)) { + serializeTls(s, tls->tls); + if (tls->certDir.has_value() || !tls->ticketSeeds.empty()) { + s.beginObject("fizz"); + if (tls->certDir.has_value()) { + s.stringField("cert_dir", tls->certDir->dir); + s.uintField( + "cert_reload_interval_s", + static_cast(tls->certDir->reloadInterval.count()) + ); + } + // Seeds are secret material; expose only how many are loaded. + s.uintField("ticket_seed_count", tls->ticketSeeds.size()); + s.endObject(); + } else { + s.nullField("fizz"); + } } else { s.boolField("insecure", true); } diff --git a/src/config/loader/ParsedConfig.h b/src/config/loader/ParsedConfig.h index 5966d092e..49191df2c 100644 --- a/src/config/loader/ParsedConfig.h +++ b/src/config/loader/ParsedConfig.h @@ -32,6 +32,32 @@ struct ParsedUdpConfig { rfl::Description<"Socket configuration", ParsedSocketConfig> socket; }; +// Options specific to the fizz TLS stack (quic_stack mvfst/proxygen_qmux). +struct ParsedFizzTlsConfig { + rfl::Description< + "Directory of certificate pairs (.pem + .key). Certificates are " + "selected by SNI against their DNS SANs (the CN only when a certificate has " + "none; wildcards match one label) and loaded up front, at startup and on " + "each rescan. cert_file/key_file or pkcs12_file, if also set, become the " + "fallback certificate for absent/unmatched SNI.", + std::optional> + cert_dir; + rfl::Description< + "Seconds between background rescans of cert_dir (picks up new, removed, and " + "changed pairs). 0 = scan once at startup, never rescan. Default 60.", + std::optional> + cert_reload_interval_s; + rfl::Description< + "Path to a session-ticket seeds file: one hex-encoded seed (at least 64 hex " + "chars) per line, '#' starts a comment; a file yielding no seeds is an error. " + "The first seed encrypts new tickets; every listed seed still decrypts, so " + "rotate by prepending a line and restarting. Share one file across relay " + "instances so TLS resumption survives restarts and works across relays. " + "Absent: a random per-process seed.", + std::optional> + ticket_seeds_file; +}; + struct ParsedListenerTlsConfig { rfl::Description<"Path to TLS certificate file", std::optional> cert_file; rfl::Description<"Path to TLS private key file", std::optional> key_file; @@ -54,7 +80,17 @@ struct ParsedListenerTlsConfig { "friendly: the secret stays out of the config file). Errors if the variable is unset.", std::optional> pkcs12_password_env; - rfl::Description<"Insecure mode, use default compiled-in cert", bool> insecure; + rfl::Description< + "Fizz-stack TLS options (quic_stack mvfst/proxygen_qmux only; picoquic " + "rejects them)", + std::optional> + fizz; + rfl::Description< + "Development only: serve the compiled-in certificate and drop client " + "verification. Rejected alongside cert_file/key_file/pkcs12_file/fizz, and " + "on quic_stack picoquic.", + bool> + insecure; }; struct ParsedAdminTlsConfig { diff --git a/src/tls/CertDirScanner.cpp b/src/tls/CertDirScanner.cpp new file mode 100644 index 000000000..95d6de927 --- /dev/null +++ b/src/tls/CertDirScanner.cpp @@ -0,0 +1,266 @@ +/* + * Copyright (c) OpenMOQ contributors. + * This source code is licensed under the Apache 2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "tls/CertDirScanner.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace openmoq::moqx::tls { + +namespace { + +namespace fs = std::filesystem; + +// strict: the problem aborts the whole scan; otherwise it is recorded and the +// offending pair is skipped. +void problem(bool strict, std::vector& warnings, std::string msg) { + if (strict) { + throw std::runtime_error(std::move(msg)); + } + warnings.push_back(std::move(msg)); +} + +// normalizeLookupKey plus validation. Throws on identities fizz would reject +// (empty, bare "*", any remaining "*" e.g. nested wildcards). Mirrors fizz +// DefaultCertManager::getKeyFromIdent + addCertIdentity's validity check. +std::string normalizeIdentity(const std::string& ident) { + if (ident.empty()) { + throw std::runtime_error("empty identity"); + } + std::string key = normalizeLookupKey(ident); + if (key.empty() || key == "." || key.find('*') != std::string::npos) { + throw std::runtime_error("invalid identity '" + ident + "'"); + } + return key; +} + +// Parse the pair's certificate (leaf only, no key material) into a +// CertDirEntry stamped with the given mtimes. Throws std::runtime_error with +// a path-annotated message. +CertDirEntry parsePair( + const std::string& certPath, + const std::string& keyPath, + fs::file_time_type certMtime, + fs::file_time_type keyMtime +) { + std::string certPem; + if (!folly::readFile(certPath.c_str(), certPem)) { + throw std::runtime_error("failed to read '" + certPath + "'"); + } + + std::vector certs; + try { + certs = folly::ssl::OpenSSLCertUtils::readCertsFromBuffer(folly::StringPiece(certPem)); + } catch (const std::exception& e) { + throw std::runtime_error("failed to parse certificate '" + certPath + "': " + e.what()); + } + if (certs.empty()) { + throw std::runtime_error("no certificate found in '" + certPath + "'"); + } + X509& leaf = *certs.front(); + + auto cn = folly::ssl::OpenSSLCertUtils::getCommonName(leaf); + std::vector rawIdents = folly::ssl::OpenSSLCertUtils::getSubjectAltNames(leaf); + if (rawIdents.empty() && cn && !cn->empty()) { + rawIdents.push_back(*cn); + } + if (rawIdents.empty()) { + throw std::runtime_error("certificate '" + certPath + "' has no DNS SANs and no CN"); + } + + CertDirEntry entry; + entry.certPath = certPath; + entry.keyPath = keyPath; + if (cn && !cn->empty()) { + entry.primaryIdentity = normalizeLookupKey(*cn); + } else if (auto subject = folly::ssl::OpenSSLCertUtils::getSubject(leaf); + subject && !subject->empty()) { + // fizz SelfCert::getIdentity() falls back to the subject DN when there is + // no CN; mirror it so resumption lookups for a CN-less cert resolve. + entry.primaryIdentity = normalizeLookupKey(*subject); + } + for (const auto& ident : rawIdents) { + std::string key; + try { + key = normalizeIdentity(ident); + } catch (const std::exception& e) { + throw std::runtime_error(std::string("certificate '") + certPath + "': " + e.what()); + } + if (std::find(entry.identities.begin(), entry.identities.end(), key) == + entry.identities.end()) { + entry.identities.push_back(std::move(key)); + } + } + + folly::ssl::EvpPkeyUniquePtr pubKey(X509_get_pubkey(&leaf)); + if (!pubKey) { + throw std::runtime_error("failed to read public key from '" + certPath + "'"); + } + fizz::openssl::KeyType keyType; + fizz::Error err; + if (fizz::openssl::CertUtils::getKeyType(keyType, err, pubKey) != fizz::Status::Success) { + throw std::runtime_error( + "unsupported key type in '" + certPath + "': " + (err.msg() ? err.msg() : "unknown") + ); + } + + entry.certMtime = certMtime; + entry.keyMtime = keyMtime; + return entry; +} + +} // namespace + +std::string normalizeLookupKey(std::string s) { + if (!s.empty() && s.front() == '*') { + s.erase(0, 1); + } + folly::toLowerAscii(s); + return s; +} + +std::vector scanCertDir( + const std::string& dir, + bool strict, + std::vector& warnings, + const std::vector* previous +) { + std::error_code ec; + fs::directory_iterator it(dir, ec); + if (ec) { + // Always fatal, even non-strict: an unopenable dir is indistinguishable + // from an empty one, and returning {} would wipe every served identity. + throw std::runtime_error("cert_dir '" + dir + "' is not readable: " + ec.message()); + } + + // Bases are collected first so orphan detection is order-independent. + std::set pemBases; + std::set keyBases; + for (const auto& de : it) { + if (!de.is_regular_file(ec)) { + continue; + } + const auto& path = de.path(); + if (path.extension() == ".pem") { + pemBases.insert(path.stem().string()); + } else if (path.extension() == ".key") { + keyBases.insert(path.stem().string()); + } + } + + for (const auto& base : keyBases) { + if (!pemBases.count(base)) { + problem( + strict, + warnings, + "cert_dir '" + dir + "': orphan key '" + base + ".key' has no matching " + base + ".pem" + ); + } + } + + std::map previousByCertPath; + if (previous) { + for (const auto& entry : *previous) { + previousByCertPath.emplace(entry.certPath, &entry); + } + } + + // Incumbents (pairs present in `previous`) claim identities before new + // arrivals: a duplicate dropped into a live directory must not steal an + // identity from the pair serving it. Sorted order breaks ties within each. + std::vector orderedBases; + orderedBases.reserve(pemBases.size()); + for (bool incumbentPass : {true, false}) { + for (const auto& base : pemBases) { + auto certPath = (fs::path(dir) / (base + ".pem")).string(); + if ((previousByCertPath.count(certPath) != 0) == incumbentPass) { + orderedBases.push_back(base); + } + } + } + + std::vector entries; + std::map identityToCert; // duplicate detection + for (const auto& base : orderedBases) { + auto certPath = (fs::path(dir) / (base + ".pem")).string(); + if (!keyBases.count(base)) { + problem( + strict, + warnings, + "cert_dir '" + dir + "': orphan cert '" + base + ".pem' has no matching " + base + ".key" + ); + continue; + } + auto keyPath = (fs::path(dir) / (base + ".key")).string(); + + // Mtimes are recorded before the content is read: a write racing the scan + // then leaves an older mtime with the newer bytes, so the next rescan + // re-parses, instead of stamping stale bytes with the new mtime forever. + std::error_code certEc; + std::error_code keyEc; + auto certMtime = fs::last_write_time(certPath, certEc); + auto keyMtime = fs::last_write_time(keyPath, keyEc); + if (certEc || keyEc) { + problem( + strict, + warnings, + "failed to stat '" + (certEc ? certPath : keyPath) + + "': " + (certEc ? certEc : keyEc).message() + ); + continue; + } + + CertDirEntry entry; + auto prev = previousByCertPath.find(certPath); + if (prev != previousByCertPath.end() && prev->second->certMtime == certMtime && + prev->second->keyMtime == keyMtime) { + entry = *prev->second; + } else { + try { + entry = parsePair(certPath, keyPath, certMtime, keyMtime); + } catch (const std::exception& e) { + problem(strict, warnings, e.what()); + continue; + } + } + + // Check every identity before claiming any, so a dropped pair leaves no + // trace in the duplicate map. + bool duplicate = false; + for (const auto& ident : entry.identities) { + auto existing = identityToCert.find(ident); + if (existing != identityToCert.end()) { + problem( + strict, + warnings, + "duplicate identity '" + ident + "' claimed by both '" + existing->second + "' and '" + + certPath + "'" + ); + duplicate = true; + } + } + if (duplicate) { + continue; // first claimant wins; drop the whole later pair + } + for (const auto& ident : entry.identities) { + identityToCert.emplace(ident, certPath); + } + entries.push_back(std::move(entry)); + } + + return entries; +} + +} // namespace openmoq::moqx::tls diff --git a/src/tls/CertDirScanner.h b/src/tls/CertDirScanner.h new file mode 100644 index 000000000..aa2109312 --- /dev/null +++ b/src/tls/CertDirScanner.h @@ -0,0 +1,58 @@ +/* + * Copyright (c) OpenMOQ contributors. + * This source code is licensed under the Apache 2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include +#include +#include + +namespace openmoq::moqx::tls { + +// One .pem/.key pair as discovered by scanCertDir. Key material is +// NOT read at scan time. +struct CertDirEntry { + std::string certPath; + std::string keyPath; + // Lowercased CN/DNS-SAN identities, wildcard "*.x" normalized to ".x" + // (fizz DefaultCertManager key form: exact lookup, then first-dot suffix). + std::vector identities; + // normalizeLookupKey of fizz SelfCert::getIdentity(): the CN, or the + // subject DN when the cert has no CN. + std::string primaryIdentity; + std::filesystem::file_time_type certMtime; + std::filesystem::file_time_type keyMtime; +}; + +// Normalize an identity or SNI value to the map-key form the entries above +// use: lowercase, leading "*" stripped ("*.x" -> ".x"). Writers and readers of +// CertDirEntry::identities must both go through this so lookups can't drift. +std::string normalizeLookupKey(std::string s); + +// Scan a directory (non-recursive) for .pem + .key pairs and +// extract identities from each certificate (DNS SANs; CN when there are none). +// +// `previous` (optional) holds an earlier scan's entries, matched by certPath: +// a pair whose cert and key mtimes both equal its previous entry's is copied +// forward without reading or parsing the files. Orphan and duplicate-identity +// detection apply to copied entries too; previous entries whose files are +// gone from the directory are ignored. +// +// Failure handling: +// - An unopenable directory throws std::runtime_error regardless of `strict`. +// - A per-pair problem is an orphan .pem/.key, an unstattable file, an +// unparsable cert, an invalid identity, or an identity claimed by two pairs +// (the incumbent, else the first in sorted order, wins). +// - strict=true: it throws. +// - strict=false: it is appended to `warnings` and the pair is skipped. +std::vector scanCertDir( + const std::string& dir, + bool strict, + std::vector& warnings, + const std::vector* previous = nullptr +); + +} // namespace openmoq::moqx::tls diff --git a/src/tls/CertLoader.h b/src/tls/CertLoader.h new file mode 100644 index 000000000..b4c7c6f22 --- /dev/null +++ b/src/tls/CertLoader.h @@ -0,0 +1,58 @@ +/* + * Copyright (c) OpenMOQ contributors. + * This source code is licensed under the Apache 2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include +#include +#include + +#include +#include +#include + +namespace openmoq::moqx::tls { + +// Build a fizz SelfCert from PEM buffers; `what` names the source in the +// error message. Throws std::runtime_error on failure. +inline std::unique_ptr makeSelfCertFromPems( + const std::string& certPem, + const std::string& keyPem, + const std::string& what +) { + std::unique_ptr cert; + fizz::Error err; + if (fizz::openssl::CertUtils::makeSelfCert(cert, err, certPem, keyPem) != fizz::Status::Success) { + throw std::runtime_error( + "failed to load TLS certificate/key " + what + ": " + + (err.msg() ? err.msg() : "unknown error") + ); + } + return cert; +} + +// Load a SelfCert from a cert/key file pair. Throws std::runtime_error naming +// the offending path. +inline std::shared_ptr +loadCertPair(const std::string& certPath, const std::string& keyPath) { + std::string certPem; + std::string keyPem; + if (!folly::readFile(certPath.c_str(), certPem)) { + throw std::runtime_error( + "failed to read TLS certificate '" + certPath + + "' - check the path exists and is readable by this process" + ); + } + if (!folly::readFile(keyPath.c_str(), keyPem)) { + throw std::runtime_error( + "failed to read TLS key '" + keyPath + + "' - check the path exists and is readable by this process" + ); + } + return makeSelfCertFromPems(certPem, keyPem, "(cert='" + certPath + "', key='" + keyPath + "')"); +} + +} // namespace openmoq::moqx::tls diff --git a/src/tls/FizzContextBuilder.cpp b/src/tls/FizzContextBuilder.cpp new file mode 100644 index 000000000..381290a04 --- /dev/null +++ b/src/tls/FizzContextBuilder.cpp @@ -0,0 +1,166 @@ +/* + * Copyright (c) OpenMOQ contributors. + * This source code is licensed under the Apache 2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "tls/FizzContextBuilder.h" + +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "tls/CertLoader.h" +#include "tls/SniCertManager.h" + +namespace openmoq::moqx::tls { + +namespace { + +// One SniCertManager per distinct option set: listeners sharing a cert_dir +// share the scan, the rescan thread, and the loaded keys. Safe to share +// (Synchronized state); dead cache slots are pruned on the next call. +std::shared_ptr sharedSniCertManager(SniCertManager::Options options) { + static folly::Synchronized>> cache; + + std::string key = options.certDir.dir; + key += '\0' + std::to_string(options.certDir.reloadInterval.count()); + key += '\0' + options.fallbackCertFile; + key += '\0' + options.fallbackKeyFile; + if (options.fallbackMaterial.has_value()) { + // Digest, not the PEM itself: this key lives in a process-lifetime static, + // which is no place for a plaintext private key. + const auto& material = *options.fallbackMaterial; + std::array digest{}; + folly::ssl::OpenSSLHash::Digest hasher; + hasher.hash_init(EVP_sha256()); + hasher.hash_update(folly::ByteRange(folly::StringPiece(material.certChainPem))); + hasher.hash_update(folly::ByteRange(folly::StringPiece(material.keyPem))); + hasher.hash_final(folly::MutableByteRange(digest.data(), digest.size())); + key += '\0' + folly::hexlify(folly::ByteRange(digest.data(), digest.size())); + } + + auto locked = cache.wlock(); + for (auto it = locked->begin(); it != locked->end();) { + it = it->second.expired() ? locked->erase(it) : std::next(it); + } + if (auto it = locked->find(key); it != locked->end()) { + if (auto existing = it->second.lock()) { + return existing; + } + } + auto manager = std::make_shared(std::move(options)); + (*locked)[key] = manager; + return manager; +} + +} // namespace + +std::shared_ptr makeCertManager(const config::ListenerTlsConfig& cfg) { + if (cfg.certDir.has_value()) { + SniCertManager::Options options; + options.certDir = *cfg.certDir; + options.fallbackCertFile = cfg.tls.certFile; + options.fallbackKeyFile = cfg.tls.keyFile; + options.fallbackMaterial = cfg.tls.material; + return sharedSniCertManager(std::move(options)); + } + + auto certManager = std::make_shared(); + if (cfg.tls.material.has_value()) { + // PKCS#12 source: in-memory PEM buffers, the key never touches disk. + certManager->addCertAndSetDefault(makeSelfCertFromPems( + cfg.tls.material->certChainPem, + cfg.tls.material->keyPem, + "(in-memory PKCS#12 material)" + )); + return certManager; + } + certManager->addCertAndSetDefault(loadCertPair(cfg.tls.certFile, cfg.tls.keyFile)); + return certManager; +} + +// Mirrors quic::samples::createFizzServerContextImpl to keep the proxygen +// sample's TLS behavior. +// TODO(#482): replace with a helper in the moxygen fork. +std::shared_ptr buildFizzServerContext( + std::shared_ptr certManager, + FizzContextOptions options +) { + auto ctx = std::make_shared(); + ctx->setCertManager(certManager); + auto ticketCipher = std::make_shared>>( + ctx->getFactoryPtr(), + std::move(certManager) + ); + if (!options.ticketSeeds.empty()) { + std::vector secrets; + secrets.reserve(options.ticketSeeds.size()); + for (const auto& seed : options.ticketSeeds) { + secrets.emplace_back(reinterpret_cast(seed.data()), seed.size()); + } + // A false return would leave the cipher secretless: tickets silently stop + // being issued and resumption dies. Config validation enforces the 32-byte + // minimum; this guards programmatic callers of the extension seam. + if (!ticketCipher->setTicketSecrets(secrets)) { + throw std::runtime_error( + "fizz rejected the TLS ticket seeds: each seed must be at least 32 bytes" + ); + } + } else { + std::array ticketSeed; + folly::Random::secureRandom(ticketSeed.data(), ticketSeed.size()); + ticketCipher->setTicketSecrets({{folly::range(ticketSeed)}}); + } + ctx->setTicketCipher(ticketCipher); + ctx->setClientAuthMode(fizz::server::ClientAuthMode::Optional); + ctx->setSupportedAlpns(options.alpns); + ctx->setAlpnMode(fizz::server::AlpnMode::Required); + ctx->setSendNewSessionTicket(true); + ctx->setEarlyDataFbOnly(false); + ctx->setVersionFallbackEnabled(false); + + fizz::server::ClockSkewTolerance tolerance; + tolerance.before = std::chrono::minutes(-5); + tolerance.after = std::chrono::minutes(5); + std::shared_ptr replayCache = + std::make_shared(); + ctx->setEarlyDataSettings(true, tolerance, std::move(replayCache)); + return ctx; +} + +std::shared_ptr +buildFizzServerContext(const config::TlsMode& mode, FizzContextOptions options) { + return std::visit( + [&options](const auto& tls) -> std::shared_ptr { + using T = std::decay_t; + if constexpr (std::is_same_v) { + return quic::samples::createFizzServerContextWithInsecureDefault( + options.alpns, + fizz::server::ClientAuthMode::None, + "", + "" + ); + } else { + options.ticketSeeds = tls.ticketSeeds; + return buildFizzServerContext(makeCertManager(tls), std::move(options)); + } + }, + mode + ); +} + +} // namespace openmoq::moqx::tls diff --git a/src/tls/FizzContextBuilder.h b/src/tls/FizzContextBuilder.h new file mode 100644 index 000000000..1fc442106 --- /dev/null +++ b/src/tls/FizzContextBuilder.h @@ -0,0 +1,59 @@ +/* + * Copyright (c) OpenMOQ contributors. + * This source code is licensed under the Apache 2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include +#include +#include + +#include +#include + +#include "config/Config.h" + +namespace openmoq::moqx::tls { + +struct FizzContextOptions { + std::vector alpns; + // Raw bytes, each at least 32 or fizz rejects the whole set; the first seed + // encrypts, all decrypt. Empty = a random per-process seed. See + // docs/config.md (ticket_seeds_file). + std::vector ticketSeeds; +}; + +// Config-to-CertManager dispatch for a secure listener: +// - fizz.cert_dir set: SniCertManager, with cert_file/key_file or PKCS#12 +// material as its fallback. +// - fizz.cert_dir unset: a single-cert DefaultCertManager from the file pair +// or in-memory material. +// +// This dispatch is the seam for alternative cert sources (HSM, KMS, ...): +// implement fizz::server::CertManager and pass it to the CertManager overload +// of buildFizzServerContext below. getCert() is synchronous, so selection must +// run from in-process state; slow remote signing belongs in the served certs +// (fizz::AsyncSelfCert), not the manager. +// +// Throws std::runtime_error with the offending paths on any load failure. +std::shared_ptr makeCertManager(const config::ListenerTlsConfig& cfg); + +// Secure fizz server context around a caller-supplied CertManager: ticket +// cipher wired to the same manager (resumption resolves certs through it), +// ALPN required, early data on, ClientAuthMode::Optional. +// Throws std::runtime_error when fizz rejects the configured ticket seeds. +std::shared_ptr buildFizzServerContext( + std::shared_ptr certManager, + FizzContextOptions options +); + +// Fizz server context for a listener. Insecure: the proxygen sample context +// with a compiled-in cert (ClientAuthMode::None; ticket seeds don't apply). +// Secure: the CertManager overload around makeCertManager(cfg). +// Throws std::runtime_error with the offending paths on any load failure. +std::shared_ptr +buildFizzServerContext(const config::TlsMode& mode, FizzContextOptions options); + +} // namespace openmoq::moqx::tls diff --git a/src/tls/SniCertManager.cpp b/src/tls/SniCertManager.cpp new file mode 100644 index 000000000..f651a9a3f --- /dev/null +++ b/src/tls/SniCertManager.cpp @@ -0,0 +1,416 @@ +/* + * Copyright (c) OpenMOQ contributors. + * This source code is licensed under the Apache 2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "tls/SniCertManager.h" + +#include +#include +#include +#include + +#include +#include + +#include "tls/CertLoader.h" + +namespace openmoq::moqx::tls { + +namespace { + +namespace fs = std::filesystem; + +// Mtime of `path`, or nullopt when it cannot be stat'ed. +std::optional mtimeOf(const std::string& path) { + std::error_code ec; + auto mtime = fs::last_write_time(path, ec); + return ec ? std::nullopt : std::optional(mtime); +} + +// First scheme in `supported` the cert can produce and the peer advertised +// (DefaultCertManager::findCert semantics). +folly::Optional selectSchemeStrict( + const std::vector& certSchemes, + const std::vector& supported, + const std::vector& peer +) { + for (auto scheme : supported) { + if (std::find(certSchemes.begin(), certSchemes.end(), scheme) != certSchemes.end() && + std::find(peer.begin(), peer.end(), scheme) != peer.end()) { + return scheme; + } + } + return folly::none; +} + +// Client-supplied SNI, lowercased. The leading-'*' strip in +// normalizeLookupKey is the insertion-side rule (fizz getKeyFromIdent) and +// must not be applied to a name the peer chose: '*foo.example.com' would then +// hit the entry for 'foo.example.com'. +std::string normalizeSni(std::string s) { + folly::toLowerAscii(s); + return s; +} + +// CertManager-contract last resort: ignore peerSigSchemes entirely. +folly::Optional selectSchemeRelaxed( + const std::vector& certSchemes, + const std::vector& supported +) { + for (auto scheme : supported) { + if (std::find(certSchemes.begin(), certSchemes.end(), scheme) != certSchemes.end()) { + return scheme; + } + } + return folly::none; +} + +} // namespace + +folly::F14FastMap> +SniCertManager::buildMap(std::vector scanned, const EntryMap* previous, bool strict) { + // Carry loaded certs across a rescan for pairs whose files are unchanged. + EntryMap previousByCertPath; + if (previous) { + for (const auto& [ident, entry] : *previous) { + previousByCertPath.emplace(entry->meta.certPath, entry); + } + } + + // Entries whose identities match the directory as it is now, and entries + // kept from the previous scan because their pair no longer loads. Kept + // separate so the claim order below can prefer the current ones. + std::vector> current; + std::vector> retained; + for (auto& meta : scanned) { + auto prev = previousByCertPath.find(meta.certPath); + const bool unchanged = prev != previousByCertPath.end() && + prev->second->meta.certMtime == meta.certMtime && + prev->second->meta.keyMtime == meta.keyMtime; + + if (unchanged) { + current.push_back(prev->second); + continue; + } + auto fresh = std::make_shared(); + fresh->meta = std::move(meta); + try { + fresh->cert = loadCertPair(fresh->meta.certPath, fresh->meta.keyPath); + current.push_back(std::move(fresh)); + } catch (const std::exception& e) { + if (strict) { + throw; + } + if (prev == previousByCertPath.end()) { + XLOG(ERR) << "cert_dir: dropping '" << fresh->meta.certPath << "': " << e.what(); + continue; + } + // Keep the previous version whole rather than pairing the new + // identities with the old cert. Its stale mtimes make the next rescan + // retry the load. + XLOG(ERR) << "cert_dir: '" << fresh->meta.certPath + << "' failed to load, keeping the previously loaded certificate: " << e.what(); + retained.push_back(prev->second); + } + } + + // Retained entries claim last: their identity set is the one the pair had + // before it stopped loading, so it must only fill names no current pair + // serves. Two pairs swapping names in one rescan window, with one of them + // failing to load, is the case that needs it. + EntryMap map; + for (const auto* group : {¤t, &retained}) { + for (const auto& entry : *group) { + for (const auto& ident : entry->meta.identities) { + auto [it, inserted] = map.emplace(ident, entry); + if (!inserted) { + XLOG(ERR) << "cert_dir: '" << entry->meta.certPath << "' does not serve '" << ident + << "': already claimed by '" << it->second->meta.certPath << "'"; + } + } + } + } + return map; +} + +folly::F14FastMap> +SniCertManager::buildPrimaryMap(const EntryMap& byIdentity) { + // A CN shared by several pairs resolves to the lowest certPath: byIdentity + // iterates in hash order, which would make the winner arbitrary and able to + // flip between rescans. + std::map> byCertPath; + for (const auto& [ident, entry] : byIdentity) { + byCertPath.emplace(entry->meta.certPath, entry); + } + EntryMap map; + for (const auto& [path, entry] : byCertPath) { + if (!entry->meta.primaryIdentity.empty()) { + map.emplace(entry->meta.primaryIdentity, entry); + } + } + return map; +} + +SniCertManager::SniCertManager(Options options) : options_(std::move(options)) { + const bool hasFileFallback = !options_.fallbackCertFile.empty(); + const bool hasMaterialFallback = options_.fallbackMaterial.has_value(); + + State initial; + std::vector warnings; // unused: strict scan throws instead + initial.byIdentity = buildMap( + scanCertDir(options_.certDir.dir, /*strict=*/true, warnings), + nullptr, + /*strict=*/true + ); + initial.byPrimary = buildPrimaryMap(initial.byIdentity); + + if (hasFileFallback) { + // Mtimes are recorded before the content is read; see the parsePair + // comment in CertDirScanner.cpp for the race this order avoids. + initial.fallbackCertMtime = mtimeOf(options_.fallbackCertFile); + initial.fallbackKeyMtime = mtimeOf(options_.fallbackKeyFile); + initial.fallback = loadCertPair(options_.fallbackCertFile, options_.fallbackKeyFile); + } else if (hasMaterialFallback) { + initial.fallback = makeSelfCertFromPems( + options_.fallbackMaterial->certChainPem, + options_.fallbackMaterial->keyPem, + "(in-memory PKCS#12 material)" + ); + } + + if (initial.byIdentity.empty()) { + if (!initial.fallback) { + throw std::runtime_error( + "cert_dir '" + options_.certDir.dir + + "' contains no certificate pairs and no fallback cert is configured" + ); + } + XLOG(WARN) << "cert_dir '" << options_.certDir.dir + << "' contains no certificate pairs; serving the fallback cert only"; + } + + *state_.wlock() = std::move(initial); + + if (options_.certDir.reloadInterval > std::chrono::seconds(0)) { + scheduler_.setThreadName("moqx-cert-rescan"); + scheduler_.addFunction( + [this] { rescan(); }, + options_.certDir.reloadInterval, + "cert-dir-rescan", + options_.certDir.reloadInterval // initial delay: the ctor just scanned + ); + scheduler_.start(); + } +} + +SniCertManager::~SniCertManager() { + scheduler_.shutdown(); +} + +fizz::Status SniCertManager::getCert( + fizz::CertMatch& ret, + fizz::Error& /* err */, + const folly::Optional& sni, + const std::vector& supportedSigSchemes, + const std::vector& peerSigSchemes, + const fizz::ClientHello& /* chlo */ +) const { + std::shared_ptr entry; + std::shared_ptr fallback; + { + auto state = state_.rlock(); + fallback = state->fallback; + if (sni) { + auto key = normalizeSni(*sni); + auto it = state->byIdentity.find(key); + if (it == state->byIdentity.end()) { + // Wildcard form: the suffix from the first dot (".example.com"). + auto dot = key.find_first_of('.'); + if (dot != std::string::npos) { + it = state->byIdentity.find(key.substr(dot)); + } + } + if (it != state->byIdentity.end()) { + entry = it->second; + } + } + } + + std::shared_ptr entryCert = entry ? entry->cert : nullptr; + + // The loaded cert is the authority on which schemes it can sign: offering + // one its key cannot produce would fail every handshake for the identity. + std::vector entrySchemes; + if (entryCert) { + entrySchemes = entryCert->getSigSchemes(); + } + + // A usable fallback must beat an entry the client cannot verify: both certs + // get the strict pass (scheme in the peer's signature_algorithms) before + // either gets the relaxed CertManager-contract pass. + if (entryCert) { + if (auto scheme = selectSchemeStrict(entrySchemes, supportedSigSchemes, peerSigSchemes)) { + ret = fizz::CertMatchStruct{std::move(entryCert), *scheme, fizz::MatchType::Direct}; + return fizz::Status::Success; + } + } + if (fallback) { + if (auto scheme = + selectSchemeStrict(fallback->getSigSchemes(), supportedSigSchemes, peerSigSchemes)) { + ret = fizz::CertMatchStruct{std::move(fallback), *scheme, fizz::MatchType::Default}; + return fizz::Status::Success; + } + } + if (entryCert) { + if (auto scheme = selectSchemeRelaxed(entrySchemes, supportedSigSchemes)) { + ret = fizz::CertMatchStruct{std::move(entryCert), *scheme, fizz::MatchType::Direct}; + return fizz::Status::Success; + } + } + if (fallback) { + if (auto scheme = selectSchemeRelaxed(fallback->getSigSchemes(), supportedSigSchemes)) { + ret = fizz::CertMatchStruct{std::move(fallback), *scheme, fizz::MatchType::Default}; + return fizz::Status::Success; + } + } + + // DefaultCertManager miss behavior: empty match, Success, no error. + ret = folly::none; + return fizz::Status::Success; +} + +std::shared_ptr SniCertManager::getCert(const std::string& identity) const { + auto key = normalizeLookupKey(identity); + + std::shared_ptr entry; + std::shared_ptr fallback; + { + auto state = state_.rlock(); + fallback = state->fallback; + // The ticket stores the cert's primary identity, so byPrimary is the + // authority: another cert can carry the same name as a SAN without being + // the one the ticket names. byIdentity then covers certs whose primary + // identity is empty. + if (auto pit = state->byPrimary.find(key); pit != state->byPrimary.end()) { + entry = pit->second; + } else if (auto it = state->byIdentity.find(key); it != state->byIdentity.end()) { + entry = it->second; + } + } + if (entry) { + return entry->cert; + } + if (fallback && normalizeLookupKey(fallback->getIdentity()) == key) { + return fallback; + } + return nullptr; +} + +void SniCertManager::rescan() noexcept { + try { + std::error_code ec; + if (!fs::is_directory(options_.certDir.dir, ec)) { + XLOG(WARN) << "cert_dir '" << options_.certDir.dir + << "' is not accessible; keeping previously loaded certificates"; + return; + } + + // Snapshot the previous state, then scan and build the new maps with no + // lock held so in-flight handshakes never wait on the rebuild. + EntryMap previousByIdentity; + std::optional prevCertMtime; + std::optional prevKeyMtime; + bool hasFallback = false; + { + auto state = state_.rlock(); + previousByIdentity = state->byIdentity; + prevCertMtime = state->fallbackCertMtime; + prevKeyMtime = state->fallbackKeyMtime; + hasFallback = state->fallback != nullptr; + } + + // Previous metas let the scan skip re-parsing unchanged pairs. Entries + // retained past a scan error hold their old mtimes while the changed file + // holds new ones, so they never suppress a needed re-parse. + std::vector prevMetas; + { + std::set seenPaths; + for (const auto& [ident, entry] : previousByIdentity) { + if (seenPaths.insert(entry->meta.certPath).second) { + prevMetas.push_back(entry->meta); + } + } + } + + std::vector warnings; + auto scanned = scanCertDir(options_.certDir.dir, /*strict=*/false, warnings, &prevMetas); + for (const auto& warning : warnings) { + XLOG(WARN) << "cert_dir rescan: " << warning; + } + std::set scannedCertPaths; + for (const auto& meta : scanned) { + scannedCertPaths.insert(meta.certPath); + } + + auto newMap = buildMap(std::move(scanned), &previousByIdentity, /*strict=*/false); + + // A scan-dropped pair whose files both still exist is a scan error, not a + // removal: keep the previous entry, which already carries a loaded cert. + // Either file missing is a removal — an orphan is a stable state the scan + // rejects on every pass, so retaining it would serve the cert forever. + for (const auto& [ident, entry] : previousByIdentity) { + if (!scannedCertPaths.count(entry->meta.certPath) && fs::exists(entry->meta.certPath, ec) && + fs::exists(entry->meta.keyPath, ec) && newMap.emplace(ident, entry).second) { + XLOG(WARN) << "cert_dir rescan: keeping previously scanned '" << entry->meta.certPath + << "' for identity '" << ident << "'"; + } + } + auto newPrimary = buildPrimaryMap(newMap); + + // Fallback file pair: refresh eagerly on mtime change so the fallback is + // always servable; keep the old cert when the new files don't load. + std::shared_ptr newFallback; + std::optional newCertMtime; + std::optional newKeyMtime; + if (!options_.fallbackCertFile.empty()) { + newCertMtime = mtimeOf(options_.fallbackCertFile); + newKeyMtime = mtimeOf(options_.fallbackKeyFile); + if (newCertMtime != prevCertMtime || newKeyMtime != prevKeyMtime) { + try { + newFallback = loadCertPair(options_.fallbackCertFile, options_.fallbackKeyFile); + XLOG(INFO) << "reloaded fallback cert '" << options_.fallbackCertFile << "'"; + } catch (const std::exception& e) { + XLOG(WARN) << "fallback cert reload failed, keeping the previous cert: " << e.what(); + } + } + } + + if (newMap.empty() && !previousByIdentity.empty()) { + if (hasFallback) { + XLOG(WARN) << "cert_dir '" << options_.certDir.dir + << "' no longer contains certificate pairs; serving the fallback cert only"; + } else { + // Nothing left to serve: every handshake from here on fails until the + // directory is repaired. + XLOG(ERR) << "cert_dir '" << options_.certDir.dir + << "' no longer contains certificate pairs and no fallback cert is configured; " + "handshakes will fail"; + } + } + + auto state = state_.wlock(); + state->byIdentity = std::move(newMap); + state->byPrimary = std::move(newPrimary); + if (newFallback) { + state->fallback = std::move(newFallback); + state->fallbackCertMtime = newCertMtime; + state->fallbackKeyMtime = newKeyMtime; + } + } catch (const std::exception& e) { + XLOG(ERR) << "cert_dir rescan failed; keeping previous state: " << e.what(); + } +} + +} // namespace openmoq::moqx::tls diff --git a/src/tls/SniCertManager.h b/src/tls/SniCertManager.h new file mode 100644 index 000000000..a6146210e --- /dev/null +++ b/src/tls/SniCertManager.h @@ -0,0 +1,118 @@ +/* + * Copyright (c) OpenMOQ contributors. + * This source code is licensed under the Apache 2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "config/Config.h" +#include "tls/CertDirScanner.h" + +namespace openmoq::moqx::tls { + +// fizz CertManager serving certs by SNI from a cert_dir (see CertDirScanner +// for the directory contract). +// - Matching follows fizz DefaultCertManager semantics: lowercase, exact +// then first-dot wildcard, fallback as MatchType::Default. +// - Cert and key material is loaded up front, on whichever thread runs the +// scan: the constructor at startup, the rescan thread afterwards. getCert +// is then a map lookup, so no handshake ever waits on disk. +// - Loading is not deferred to first use. fizz::server::CertManager::getCert +// is synchronous (fizz::Status is Fail/Success, the cert comes back through +// a CertMatch& out-param), so a deferred load would have to read from the +// IO thread mid-handshake, where a slow or hung cert_dir mount stalls every +// other connection on that thread. +// - The directory can be rescanned at runtime without dropping in-flight +// handshakes. +// +// The session-ticket cipher must share this instance: +// - Resumption resolves certs through getCert(identity), keyed by the cert's +// primary identity as fizz stores it in the ticket. +// - A removed identity resolves to nullptr; fizz records that as an absent +// server cert on the resumed session. +class SniCertManager : public fizz::server::CertManager { +public: + struct Options { + config::CertDirConfig certDir; + // Fallback cert for absent/unmatched SNI. At most one source; with + // neither, unmatched SNI fails the handshake. + // File pair: refreshed on rescan when the files change. + std::string fallbackCertFile; + std::string fallbackKeyFile; + // In-memory PEM material: never refreshed. + std::optional fallbackMaterial; + }; + + // Strict initial scan and load; throws std::runtime_error on any problem. + // Starts the background rescan when reloadInterval > 0. + explicit SniCertManager(Options options); + ~SniCertManager() override; + + fizz::Status getCert( + fizz::CertMatch& ret, + fizz::Error& err, + const folly::Optional& sni, + const std::vector& supportedSigSchemes, + const std::vector& peerSigSchemes, + const fizz::ClientHello& chlo + ) const override; + + std::shared_ptr getCert(const std::string& identity) const override; + + // Non-strict rescan of the dir + fallback refresh. Never throws: problems + // are logged and the previously loaded certs keep serving. + void rescan() noexcept; + +private: + struct Entry { + CertDirEntry meta; + // Loaded on the scanning thread before the entry is published, and never + // written again; handshakes only read it. Never null: buildMap drops a + // pair it cannot load. + std::shared_ptr cert; + }; + using EntryMap = folly::F14FastMap>; + struct State { + // Normalized identity -> entry; entries with several identities appear + // under each. shared_ptr so a rescan can swap the map while in-flight + // handshakes keep their refs. + EntryMap byIdentity; + // Normalized primary identity (fizz getIdentity(): CN, or subject DN + // without one) -> entry, for ticket-resumption lookups; the lowest + // certPath wins when several certs share one. + EntryMap byPrimary; + std::shared_ptr fallback; + // Empty when the file could not be stat'ed: an unreadable mtime is not a + // change, and recording a sentinel would force a reload every rescan. + std::optional fallbackCertMtime; + std::optional fallbackKeyMtime; + }; + + // Load every scanned pair, reusing `previous`'s entry for a pair whose + // files are unchanged. strict=true throws on the first load failure; + // strict=false logs it and keeps the pair's previous version, dropping the + // pair when there is none. A kept previous version claims its identities + // only where no currently loading pair claims them. + static EntryMap + buildMap(std::vector scanned, const EntryMap* previous, bool strict); + static EntryMap buildPrimaryMap(const EntryMap& byIdentity); + + Options options_; + folly::Synchronized state_; + // Declared last so its thread stops before the members rescan() touches are + // destroyed. + folly::FunctionScheduler scheduler_; +}; + +} // namespace openmoq::moqx::tls diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index b50b028c5..cbcef19a8 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -144,6 +144,17 @@ target_include_directories(moqx_state_response_test PRIVATE ${PROJECT_SOURCE_DIR}/src ) +moqx_add_gtest(moqx_tls_test + SRCS + tls/CertDirScannerTest.cpp + tls/FizzContextBuilderTest.cpp + tls/SniCertManagerTest.cpp + LIBS moqx_core OpenSSL::Crypto GTest::gtest_main GTest::gmock +) +target_include_directories(moqx_tls_test PRIVATE + ${PROJECT_SOURCE_DIR}/src +) + moqx_add_gtest(moqx_track_stats_registry_test SRCS stats/TrackStatsRegistryTest.cpp LIBS moqx_core GTest::gtest_main GTest::gmock @@ -247,6 +258,14 @@ set_tests_properties(qmux_relay PROPERTIES ENVIRONMENT "MOQBIN=${MOXYGEN_BIN_DIR}" ) +add_test( + NAME sni_multi_cert + COMMAND bash ${PROJECT_SOURCE_DIR}/test/test_sni.sh $ +) +set_tests_properties(sni_multi_cert PROPERTIES + TIMEOUT 60 +) + add_test( NAME admin_info_endpoint COMMAND bash ${PROJECT_SOURCE_DIR}/test/test_admin_info.sh $ diff --git a/test/config/ConfigResolverTest.cpp b/test/config/ConfigResolverTest.cpp index fc987bacc..2efb716f2 100644 --- a/test/config/ConfigResolverTest.cpp +++ b/test/config/ConfigResolverTest.cpp @@ -209,15 +209,14 @@ TEST(ResolveConfig, PortZero) { EXPECT_THAT(result.error(), HasSubstr("port")); } -TEST(ResolveConfig, InsecureWithCertsWarning) { +TEST(ResolveConfig, InsecureWithCertsRejected) { auto cfg = makeMinimalInsecureConfig(); cfg.listeners.value()[0].tls.value().cert_file = std::string("/some/cert.pem"); cfg.listeners.value()[0].tls.value().key_file = std::string("/some/key.pem"); auto result = resolveConfig(cfg); - ASSERT_TRUE(result.hasValue()); - ASSERT_FALSE(result.value().warnings.empty()); - EXPECT_THAT(result.value().warnings[0], HasSubstr("ignored")); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("insecure=true is mutually exclusive")); } // #459: an empty/unresolvable bind address must fail as a clean config error, @@ -268,8 +267,8 @@ TEST(ResolveConfig, Pkcs12HappyPathPopulatesMaterial) { auto result = resolveConfig(cfg); ASSERT_TRUE(result.hasValue()) << result.error(); const auto& mode = result.value().config.listeners[0].tlsMode; - ASSERT_TRUE(std::holds_alternative(mode)); - const auto& resolved = std::get(mode); + ASSERT_TRUE(std::holds_alternative(mode)); + const auto& resolved = std::get(mode).tls; ASSERT_TRUE(resolved.material.has_value()); EXPECT_THAT(resolved.material->certChainPem, HasSubstr("BEGIN CERTIFICATE")); EXPECT_THAT(resolved.material->keyPem, HasSubstr("PRIVATE KEY")); @@ -356,14 +355,317 @@ TEST(ResolveConfig, Pkcs12PicoquicRejected) { EXPECT_THAT(result.error(), HasSubstr("does not support pkcs12_file")); } -TEST(ResolveConfig, InsecureIgnoresPkcs12Warning) { +TEST(ResolveConfig, InsecureWithPkcs12Rejected) { auto cfg = makeMinimalInsecureConfig(); cfg.listeners.value()[0].tls.value().pkcs12_file = std::string("/some/bundle.p12"); auto result = resolveConfig(cfg); - ASSERT_TRUE(result.hasValue()); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("insecure=true is mutually exclusive")); + // Only the source actually set is named. + EXPECT_THAT(result.error(), HasSubstr("pkcs12_file")); + EXPECT_THAT(result.error(), ::testing::Not(HasSubstr("cert_file"))); +} + +// --- fizz.cert_dir (SNI multi-cert) --- + +TEST(ResolveConfig, CertDirAloneValid) { + auto cfg = makeMinimalInsecureConfig(); + auto& tls = cfg.listeners.value()[0].tls.value(); + tls.insecure = false; + ParsedFizzTlsConfig fizz; + fizz.cert_dir = ::testing::TempDir(); + tls.fizz = std::optional{std::move(fizz)}; + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasValue()) << result.error(); + const auto& mode = result.value().config.listeners[0].tlsMode; + ASSERT_TRUE(std::holds_alternative(mode)); + const auto& resolved = std::get(mode); + ASSERT_TRUE(resolved.certDir.has_value()); + EXPECT_EQ(resolved.certDir->dir, ::testing::TempDir()); + EXPECT_EQ(resolved.certDir->reloadInterval, std::chrono::seconds(60)); + EXPECT_THAT(resolved.tls.certFile, IsEmpty()); +} + +TEST(ResolveConfig, CertDirWithFallbackPairValid) { + auto cfg = makeMinimalInsecureConfig(); + auto& tls = cfg.listeners.value()[0].tls.value(); + tls.insecure = false; + tls.cert_file = std::string("/some/cert.pem"); + tls.key_file = std::string("/some/key.pem"); + ParsedFizzTlsConfig fizz; + fizz.cert_dir = ::testing::TempDir(); + fizz.cert_reload_interval_s = uint32_t{5}; + tls.fizz = std::optional{std::move(fizz)}; + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasValue()) << result.error(); + const auto& resolved = std::get(result.value().config.listeners[0].tlsMode); + ASSERT_TRUE(resolved.certDir.has_value()); + EXPECT_EQ(resolved.certDir->reloadInterval, std::chrono::seconds(5)); + EXPECT_EQ(resolved.tls.certFile, "/some/cert.pem"); + EXPECT_EQ(resolved.tls.keyFile, "/some/key.pem"); +} + +TEST(ResolveConfig, CertDirWithPkcs12FallbackValid) { + auto der = test::makeSelfSignedPkcs12Der("s3cret"); + test::TempFile p12(der, ".p12"); + test::TempFile pwFile("s3cret", ".txt"); + + auto cfg = makeMinimalInsecureConfig(); + auto& tls = cfg.listeners.value()[0].tls.value(); + tls.insecure = false; + tls.pkcs12_file = p12.path(); + tls.pkcs12_password_file = pwFile.path(); + ParsedFizzTlsConfig fizz; + fizz.cert_dir = ::testing::TempDir(); + tls.fizz = std::optional{std::move(fizz)}; + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasValue()) << result.error(); + const auto& resolved = std::get(result.value().config.listeners[0].tlsMode); + EXPECT_TRUE(resolved.certDir.has_value()); + EXPECT_TRUE(resolved.tls.material.has_value()); +} + +TEST(ResolveConfig, CertDirReloadIntervalZeroDisables) { + auto cfg = makeMinimalInsecureConfig(); + auto& tls = cfg.listeners.value()[0].tls.value(); + tls.insecure = false; + ParsedFizzTlsConfig fizz; + fizz.cert_dir = ::testing::TempDir(); + fizz.cert_reload_interval_s = uint32_t{0}; + tls.fizz = std::optional{std::move(fizz)}; + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasValue()) << result.error(); + const auto& resolved = std::get(result.value().config.listeners[0].tlsMode); + ASSERT_TRUE(resolved.certDir.has_value()); + EXPECT_EQ(resolved.certDir->reloadInterval, std::chrono::seconds(0)); +} + +TEST(ResolveConfig, TicketSeedsFileValid) { + // Two seeds with a comment line, a blank line, and a trailing comment; the + // decoded raw bytes must come out in file order (first = encryption seed). + std::string contents = "# rotation: prepend a fresh seed\n\n" + std::string(64, 'a') + "\n" + + std::string(64, 'b') + " # previous, still decrypts\n"; + test::TempFile seeds(contents, ".seeds"); + + auto cfg = makeMinimalInsecureConfig(); + auto& tls = cfg.listeners.value()[0].tls.value(); + tls.insecure = false; + tls.cert_file = std::string("/some/cert.pem"); + tls.key_file = std::string("/some/key.pem"); + ParsedFizzTlsConfig fizz; + fizz.ticket_seeds_file = seeds.path(); + tls.fizz = std::optional{std::move(fizz)}; + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasValue()) << result.error(); + const auto& resolved = std::get(result.value().config.listeners[0].tlsMode); + ASSERT_EQ(resolved.ticketSeeds.size(), 2u); + EXPECT_EQ(resolved.ticketSeeds[0], std::string(32, '\xaa')); + EXPECT_EQ(resolved.ticketSeeds[1], std::string(32, '\xbb')); +} + +TEST(ResolveConfig, TicketSeedsFileInvalidHexRejected) { + test::TempFile seeds("not-hex-content\n", ".seeds"); + + auto cfg = makeMinimalInsecureConfig(); + auto& tls = cfg.listeners.value()[0].tls.value(); + tls.insecure = false; + tls.cert_file = std::string("/some/cert.pem"); + tls.key_file = std::string("/some/key.pem"); + ParsedFizzTlsConfig fizz; + fizz.ticket_seeds_file = seeds.path(); + tls.fizz = std::optional{std::move(fizz)}; + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("not a hex-encoded seed")); + EXPECT_THAT(result.error(), HasSubstr("line 1")); +} + +TEST(ResolveConfig, TicketSeedsFileShortSeedRejected) { + test::TempFile seeds(std::string(32, 'a') + "\n", ".seeds"); // 16 bytes decoded + + auto cfg = makeMinimalInsecureConfig(); + auto& tls = cfg.listeners.value()[0].tls.value(); + tls.insecure = false; + tls.cert_file = std::string("/some/cert.pem"); + tls.key_file = std::string("/some/key.pem"); + ParsedFizzTlsConfig fizz; + fizz.ticket_seeds_file = seeds.path(); + tls.fizz = std::optional{std::move(fizz)}; + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("at least 32 bytes")); +} + +TEST(ResolveConfig, TicketSeedsFileEmptyRejected) { + test::TempFile seeds("# only a comment\n\n", ".seeds"); + + auto cfg = makeMinimalInsecureConfig(); + auto& tls = cfg.listeners.value()[0].tls.value(); + tls.insecure = false; + tls.cert_file = std::string("/some/cert.pem"); + tls.key_file = std::string("/some/key.pem"); + ParsedFizzTlsConfig fizz; + fizz.ticket_seeds_file = seeds.path(); + tls.fizz = std::optional{std::move(fizz)}; + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("contains no seeds")); +} + +TEST(ResolveConfig, TicketSeedsFileUnreadableRejected) { + auto cfg = makeMinimalInsecureConfig(); + auto& tls = cfg.listeners.value()[0].tls.value(); + tls.insecure = false; + tls.cert_file = std::string("/some/cert.pem"); + tls.key_file = std::string("/some/key.pem"); + ParsedFizzTlsConfig fizz; + fizz.ticket_seeds_file = std::string("/nonexistent/moqx-seeds-test"); + tls.fizz = std::optional{std::move(fizz)}; + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("failed to read ticket_seeds_file")); +} + +TEST(ResolveConfig, PicoquicEmptyFizzBlockAccepted) { + auto cfg = makeMinimalInsecureConfig(); + auto& listener = cfg.listeners.value()[0]; + listener.quic_stack = std::string("picoquic"); + auto& tls = listener.tls.value(); + tls.insecure = false; + tls.cert_file = std::string("/some/cert.pem"); + tls.key_file = std::string("/some/key.pem"); + // Every fizz sub-field is optional, so `fizz: {}` parses into an engaged + // optional; with no option actually set it must not trip the pico guard. + tls.fizz = std::optional{ParsedFizzTlsConfig{}}; + + auto result = resolveConfig(cfg); + EXPECT_TRUE(result.hasValue()) << (result.hasError() ? result.error() : ""); +} + +TEST(ResolveConfig, CertDirHalfFallbackPairRejected) { + auto cfg = makeMinimalInsecureConfig(); + auto& tls = cfg.listeners.value()[0].tls.value(); + tls.insecure = false; + tls.cert_file = std::string("/some/cert.pem"); + ParsedFizzTlsConfig fizz; + fizz.cert_dir = ::testing::TempDir(); + tls.fizz = std::optional{std::move(fizz)}; + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("must be set together")); +} + +TEST(ResolveConfig, CertDirNonexistentRejected) { + auto cfg = makeMinimalInsecureConfig(); + auto& tls = cfg.listeners.value()[0].tls.value(); + tls.insecure = false; + ParsedFizzTlsConfig fizz; + fizz.cert_dir = std::string("/nonexistent/moqx-cert-dir"); + tls.fizz = std::optional{std::move(fizz)}; + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("does not exist or is not a directory")); +} + +TEST(ResolveConfig, CertDirPicoquicRejected) { + auto cfg = makeMinimalInsecureConfig(); + auto& tls = cfg.listeners.value()[0].tls.value(); + tls.insecure = false; + tls.cert_file = std::string("/some/cert.pem"); + tls.key_file = std::string("/some/key.pem"); + ParsedFizzTlsConfig fizz; + fizz.cert_dir = ::testing::TempDir(); + tls.fizz = std::optional{std::move(fizz)}; + cfg.listeners.value()[0].quic_stack = std::string("picoquic"); + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("does not support tls.fizz")); +} + +TEST(ResolveConfig, ReloadIntervalWithoutCertDirWarns) { + auto cfg = makeMinimalInsecureConfig(); + auto& tls = cfg.listeners.value()[0].tls.value(); + tls.insecure = false; + tls.cert_file = std::string("/some/cert.pem"); + tls.key_file = std::string("/some/key.pem"); + ParsedFizzTlsConfig fizz; + fizz.cert_reload_interval_s = uint32_t{30}; + tls.fizz = std::optional{std::move(fizz)}; + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasValue()) << result.error(); + ASSERT_FALSE(result.value().warnings.empty()); + EXPECT_THAT(result.value().warnings[0], HasSubstr("has no effect without fizz.cert_dir")); +} + +TEST(ResolveConfig, InsecureWithFizzRejected) { + auto cfg = makeMinimalInsecureConfig(); + auto& tls = cfg.listeners.value()[0].tls.value(); + ParsedFizzTlsConfig fizz; + fizz.cert_dir = ::testing::TempDir(); + tls.fizz = std::optional{std::move(fizz)}; + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("insecure=true is mutually exclusive")); +} + +TEST(ResolveConfig, InsecureWithTicketSeedsWarns) { + auto cfg = makeMinimalInsecureConfig(); + ParsedFizzTlsConfig fizz; + fizz.ticket_seeds_file = std::string("/some/seeds.txt"); + cfg.listeners.value()[0].tls.value().fizz = std::optional{std::move(fizz)}; + + // Seeds never reach the compiled-in cert path, but nothing weaker gets + // served for ignoring them, so this is not fatal. + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasValue()) << result.error(); + ASSERT_FALSE(result.value().warnings.empty()); + EXPECT_THAT( + result.value().warnings[0], + HasSubstr("fizz.ticket_seeds_file has no effect with insecure=true") + ); +} + +TEST(ResolveConfig, InsecureWithCertReloadIntervalWarns) { + auto cfg = makeMinimalInsecureConfig(); + ParsedFizzTlsConfig fizz; + fizz.cert_reload_interval_s = uint32_t{30}; + cfg.listeners.value()[0].tls.value().fizz = std::optional{std::move(fizz)}; + + // The interval never reaches the compiled-in cert path, but nothing weaker + // gets served for ignoring it, so this is not fatal. + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasValue()) << result.error(); ASSERT_FALSE(result.value().warnings.empty()); - EXPECT_THAT(result.value().warnings[0], HasSubstr("ignored")); + EXPECT_THAT( + result.value().warnings[0], + HasSubstr("fizz.cert_reload_interval_s has no effect with insecure=true") + ); +} + +TEST(ResolveConfig, InsecureWithEmptyFizzBlockAccepted) { + auto cfg = makeMinimalInsecureConfig(); + // `fizz: {}` sets no option, so it stays inert rather than colliding with + // insecure. + cfg.listeners.value()[0].tls.value().fizz = + std::optional{ParsedFizzTlsConfig{}}; + + auto result = resolveConfig(cfg); + EXPECT_TRUE(result.hasValue()) << (result.hasError() ? result.error() : ""); } TEST(ResolveConfig, AdminTlsRequiresCertOrPkcs12) { @@ -418,7 +720,8 @@ TEST(ResolveConfig, Pkcs12PasswordFromEnv) { auto result = resolveConfig(cfg); ::unsetenv("MOQX_TEST_P12_PW"); ASSERT_TRUE(result.hasValue()) << result.error(); - const auto& resolved = std::get(result.value().config.listeners[0].tlsMode); + const auto& resolved = + std::get(result.value().config.listeners[0].tlsMode).tls; ASSERT_TRUE(resolved.material.has_value()); EXPECT_THAT(resolved.material->keyPem, HasSubstr("PRIVATE KEY")); } @@ -1056,8 +1359,8 @@ TEST(ResolveConfig, FullTls) { EXPECT_EQ(resolved.listeners[0].endpoint, "/relay"); EXPECT_EQ(resolved.listeners[0].moqtVersions, "14,16"); - ASSERT_TRUE(std::holds_alternative(resolved.listeners[0].tlsMode)); - const auto& creds = std::get(resolved.listeners[0].tlsMode); + ASSERT_TRUE(std::holds_alternative(resolved.listeners[0].tlsMode)); + const auto& creds = std::get(resolved.listeners[0].tlsMode).tls; EXPECT_EQ(creds.certFile, "/etc/ssl/cert.pem"); EXPECT_EQ(creds.keyFile, "/etc/ssl/key.pem"); } diff --git a/test/config/ConfigSerializerTest.cpp b/test/config/ConfigSerializerTest.cpp index 3ca7991ac..c35d71b26 100644 --- a/test/config/ConfigSerializerTest.cpp +++ b/test/config/ConfigSerializerTest.cpp @@ -32,6 +32,14 @@ static_assert( rfl::internal::num_fields == 4, "TlsConfig changed — update serializeTls()" ); +static_assert( + rfl::internal::num_fields == 3, + "ListenerTlsConfig changed — update serializeListenerTls()" +); +static_assert( + rfl::internal::num_fields == 2, + "CertDirConfig changed — update serializeListenerTls()" +); static_assert( rfl::internal::num_fields == 10, "QuicConfig changed — update serializeQuic()" @@ -177,7 +185,12 @@ Config makeFullConfig() { ListenerConfig l; l.name = "main"; l.address = folly::SocketAddress("::", 4433); - l.tlsMode = TlsConfig{"/etc/relay.crt", "/etc/relay.key", {}}; + l.tlsMode = ListenerTlsConfig{ + TlsConfig{"/etc/relay.crt", "/etc/relay.key", {}, std::nullopt}, + CertDirConfig{"/etc/certs", std::chrono::seconds(30)}, + // Recognizable raw seed bytes: the leak check below scans the dump for them. + {std::string(32, 'S'), std::string(32, 'T')} + }; l.endpoint = "/moq-relay"; l.moqtVersions = "16"; l.quicStack = QuicStack::Mvfst; @@ -216,6 +229,16 @@ TEST(ConfigSerializerTest, VisitsAllSections) { EXPECT_EQ(sink.scalars["listeners.*.name"], "main"); EXPECT_EQ(sink.scalars["listeners.*.quic_stack"], "mvfst"); EXPECT_EQ(sink.scalars["listeners.*.tls.key_file"], "/etc/relay.key"); + EXPECT_EQ(sink.scalars["listeners.*.tls.fizz.cert_dir"], "/etc/certs"); + EXPECT_EQ(sink.scalars["listeners.*.tls.fizz.cert_reload_interval_s"], "30"); + // Seeds are secret material: only the count may appear in the dump. + EXPECT_EQ(sink.scalars["listeners.*.tls.fizz.ticket_seed_count"], "2"); + for (const auto& [key, value] : sink.scalars) { + EXPECT_EQ(value.find("SSSS"), std::string::npos) << key; + EXPECT_EQ(value.find("TTTT"), std::string::npos) << key; + } + // Fizz options are listener-only; the admin TLS dump must not grow the key. + EXPECT_EQ(sink.scalars.count("admin.tls.fizz"), 0u); EXPECT_EQ(sink.scalars["listeners.*.quic.cc_algo"], "bbr"); EXPECT_EQ(sink.scalars["listeners.*.mvfst.bbr2.exit_startup_on_loss"], "true"); @@ -256,6 +279,27 @@ TEST(ConfigSerializerTest, AnonymousClaimEmptyNamespaceMatchSerializesAsEmptyArr EXPECT_EQ(sink.scalars.count("services.default.auth.anonymous_claim.*.namespace_segments"), 0u); } +TEST(ConfigSerializerTest, ListenerWithoutFizzOptionsEmitsNullFizz) { + Config cfg = makeFullConfig(); + auto& tls = std::get(cfg.listeners[0].tlsMode); + tls.certDir.reset(); + tls.ticketSeeds.clear(); + RecordingSink sink; + serializeConfig(cfg, sink); + + EXPECT_EQ(sink.scalars["listeners.*.tls.fizz"], "null"); +} + +TEST(ConfigSerializerTest, TicketSeedsWithoutCertDirEmitFizzObject) { + Config cfg = makeFullConfig(); + std::get(cfg.listeners[0].tlsMode).certDir.reset(); + RecordingSink sink; + serializeConfig(cfg, sink); + + EXPECT_EQ(sink.scalars["listeners.*.tls.fizz.ticket_seed_count"], "2"); + EXPECT_EQ(sink.scalars.count("listeners.*.tls.fizz.cert_dir"), 0u); +} + TEST(ConfigSerializerTest, RedactsHmacSecret) { Config cfg = makeFullConfig(); RecordingSink sink; diff --git a/test/config/Pkcs12TestUtils.h b/test/config/Pkcs12TestUtils.h index f6b00e4d7..524ac4aba 100644 --- a/test/config/Pkcs12TestUtils.h +++ b/test/config/Pkcs12TestUtils.h @@ -6,56 +6,29 @@ #pragma once -#include -#include -#include #include #include -#include -#include #include -#include #include -#include + +#include "../tls/CertTestUtils.h" +#include "../util/TempDir.h" namespace openmoq::moqx::config::test { +using moqx::test::TempFile; + // Build a self-signed RSA cert + key and pack them into a PKCS#12 bundle, // returning the DER bytes. `password` may be empty (password-less bundle). -// Throws std::runtime_error on any OpenSSL failure. Test-only. +// Throws std::runtime_error on any OpenSSL failure. inline std::string makeSelfSignedPkcs12Der(const std::string& password) { - EVP_PKEY* pkey = EVP_RSA_gen(2048); - if (!pkey) { - throw std::runtime_error("EVP_RSA_gen failed"); - } - X509* x509 = X509_new(); - if (!x509) { - EVP_PKEY_free(pkey); - throw std::runtime_error("X509_new failed"); - } - ASN1_INTEGER_set(X509_get_serialNumber(x509), 1); - X509_gmtime_adj(X509_getm_notBefore(x509), 0); - X509_gmtime_adj(X509_getm_notAfter(x509), 3600); - X509_set_pubkey(x509, pkey); - X509_NAME* name = X509_get_subject_name(x509); - X509_NAME_add_entry_by_txt( - name, - "CN", - MBSTRING_ASC, - reinterpret_cast("moqx-test"), - -1, - -1, - 0 - ); - X509_set_issuer_name(x509, name); // self-signed - bool ok = X509_sign(x509, pkey, EVP_sha256()) > 0; + auto [pkey, x509] = + moqx::test::makeSelfSignedCert("moqx-test", {}, moqx::test::TestKeyType::RSA2048); - PKCS12* p12 = nullptr; - if (ok) { - // nid_key/nid_cert 0 => OpenSSL defaults; iter/mac_iter 0 => defaults. - p12 = PKCS12_create(password.c_str(), "moqx-test", pkey, x509, /*ca=*/nullptr, 0, 0, 0, 0, 0); - } + // nid_key/nid_cert 0 => OpenSSL defaults; iter/mac_iter 0 => defaults. + PKCS12* p12 = + PKCS12_create(password.c_str(), "moqx-test", pkey, x509, /*ca=*/nullptr, 0, 0, 0, 0, 0); std::string der; if (p12) { @@ -81,29 +54,4 @@ inline std::string makeSelfSignedPkcs12Der(const std::string& password) { return der; } -// RAII temp file: writes the given bytes (binary) to a unique path, removes it -// on destruction. Test-only. -class TempFile { -public: - TempFile(std::string_view bytes, std::string_view suffix) { - static std::atomic counter{0}; - path_ = - std::filesystem::temp_directory_path() / ("moqx_test_" + std::to_string(::getpid()) + "_" + - std::to_string(counter++) + std::string(suffix)); - std::ofstream ofs(path_, std::ios::binary); - ofs.write(bytes.data(), static_cast(bytes.size())); - } - ~TempFile() { - std::error_code ec; - std::filesystem::remove(path_, ec); - } - TempFile(const TempFile&) = delete; - TempFile& operator=(const TempFile&) = delete; - - std::string path() const { return path_.string(); } - -private: - std::filesystem::path path_; -}; - } // namespace openmoq::moqx::config::test diff --git a/test/config/TestUtils.h b/test/config/TestUtils.h index 64200d625..23cd33aa5 100644 --- a/test/config/TestUtils.h +++ b/test/config/TestUtils.h @@ -6,33 +6,15 @@ #pragma once -#include -#include -#include -#include #include -namespace openmoq::moqx::config::test { - -// RAII helper: writes YAML content to a unique temp file, removes it on destruction. -class TempYamlFile { -public: - explicit TempYamlFile(std::string_view content) { - static std::atomic counter{0}; - path_ = std::filesystem::temp_directory_path() / - ("moqx_test_" + std::to_string(::getpid()) + "_" + std::to_string(counter++) + ".yaml"); - std::ofstream ofs(path_); - ofs << content; - } - ~TempYamlFile() { std::filesystem::remove(path_); } +#include "../util/TempDir.h" - TempYamlFile(const TempYamlFile&) = delete; - TempYamlFile& operator=(const TempYamlFile&) = delete; - - std::string path() const { return path_.string(); } +namespace openmoq::moqx::config::test { -private: - std::filesystem::path path_; +// TempFile with the ".yaml" suffix baked in. +struct TempYamlFile : moqx::test::TempFile { + explicit TempYamlFile(std::string_view content) : TempFile(content, ".yaml") {} }; } // namespace openmoq::moqx::config::test diff --git a/test/test_ports.sh b/test/test_ports.sh index d9c409e2a..1fd20c2d9 100644 --- a/test/test_ports.sh +++ b/test/test_ports.sh @@ -68,3 +68,7 @@ TEST_ADMIN_TRACK_METRICS_ADMIN=19685 # test_admin_state.sh TEST_ADMIN_STATE_LISTEN=19686 TEST_ADMIN_STATE_ADMIN=19687 + +# test_sni.sh +TEST_SNI_LISTEN=19688 +TEST_SNI_ADMIN=19689 diff --git a/test/test_sni.sh b/test/test_sni.sh new file mode 100755 index 000000000..1fa8a829d --- /dev/null +++ b/test/test_sni.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# test_sni.sh — end-to-end test of SNI-based multi-cert selection (tls.fizz.cert_dir). +# +# Setup: one moqx relay, quic_stack: proxygen_qmux (QMUX-on-TCP + Fizz), +# a cert_dir of two generated certs plus a cert_file/key_file fallback. +# Probe: `openssl s_client -servername` handshakes against the TCP port, +# asserting the served leaf CN: +# - per-hostname certs for the two cert_dir identities (incl. a wildcard), +# - the fallback cert for an unknown name, +# - a cert added to the dir is served after the rescan interval. +# +# s_client speaks TLS-over-TCP, hence the qmux listener. +# The mvfst listener uses the same builder (src/tls/FizzContextBuilder.h). +# +# Usage: bash test/test_sni.sh [path/to/moqx] + +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +BINARY="${1:-}" +if [[ -z "$BINARY" ]]; then + BINARY="$(ls -t "$REPO"/build/*/moqx 2>/dev/null | head -1 || true)" + BINARY="${BINARY:-$REPO/build/default/moqx}" +fi +# shellcheck source=test_ports.sh +source "$REPO/test/test_ports.sh" +# shellcheck source=test_versions.sh +source "$REPO/test/test_versions.sh" + +LISTEN_PORT=$TEST_SNI_LISTEN +ADMIN_PORT=$TEST_SNI_ADMIN +# Matches getAlpnFromVersion(, useStandard=true) for the pinned draft. +ALPN="moqt-$(tr -dc '0-9' <<<"$MOQT_TEST_VERSIONS" | head -c2)" + +if [[ ! -x "$BINARY" ]]; then + echo "ERROR: binary not found or not executable: $BINARY" >&2 + exit 1 +fi + +TMPDIR_SCRIPT="$(mktemp -d)" +CERT_DIR="$TMPDIR_SCRIPT/certs" +mkdir -p "$CERT_DIR" +MOQX_PID="" +cleanup() { + # Guarded, not `[[ ... ]] && kill`: that list returns 1 with no PID, and + # under `set -e` the trap would abort before the rm below. + if [[ -n "$MOQX_PID" ]]; then + kill "$MOQX_PID" 2>/dev/null || true + wait "$MOQX_PID" 2>/dev/null || true + fi + rm -rf "$TMPDIR_SCRIPT" +} +trap cleanup EXIT + +# $1 = base filename (in CERT_DIR unless absolute), $2 = CN, $3 = optional SAN list +make_cert() { + local base="$1" cn="$2" sans="${3:-}" + local dir="$CERT_DIR" + [[ "$base" == /* ]] && dir="$(dirname "$base")" && base="$(basename "$base")" + local args=(-x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 1 + -subj "/CN=$cn" -keyout "$dir/$base.key" -out "$dir/$base.pem") + [[ -n "$sans" ]] && args+=(-addext "subjectAltName=$sans") + openssl req "${args[@]}" 2>/dev/null +} + +make_cert a "a.example.com" +make_cert wild "*.wild.example.com" "DNS:*.wild.example.com" +mkdir -p "$TMPDIR_SCRIPT/fallback" +make_cert "$TMPDIR_SCRIPT/fallback/fb" "fallback.example.com" + +RESCAN_INTERVAL=1 +cat > "$TMPDIR_SCRIPT/config.yaml" </dev/null 2>&1; do + if (( $(date +%s) >= deadline )); then + echo "ERROR: moqx did not become ready in time" >&2 + exit 1 + fi + sleep 0.1 +done + +# $1 = SNI to send ("" = none), prints the served leaf cert's subject line. +served_subject() { + local sni="$1" + local args=(-connect "localhost:${LISTEN_PORT}" -alpn "$ALPN") + [[ -n "$sni" ]] && args+=(-servername "$sni") || args+=(-noservername) + echo | openssl s_client "${args[@]}" 2>/dev/null | openssl x509 -noout -subject 2>/dev/null +} + +check() { + local label="$1" sni="$2" want_cn="$3" + local subject + subject="$(served_subject "$sni")" + if [[ "$subject" != *"$want_cn"* ]]; then + echo "FAIL [${label}]: SNI '${sni}' served '${subject}', expected CN ${want_cn}" >&2 + exit 1 + fi + echo "PASS [${label}]" +} + +check "exact match" "a.example.com" "a.example.com" +check "wildcard match" "x.wild.example.com" "*.wild.example.com" +check "unknown name -> fallback" "unknown.example.com" "fallback.example.com" +check "no SNI -> fallback" "" "fallback.example.com" + +# A pair dropped into the dir is picked up by the background rescan. +# (No "before" assertion: the timer may fire between the write and a probe.) +make_cert late "late.example.com" +sleep $(( RESCAN_INTERVAL + 1 )) +check "after rescan" "late.example.com" "late.example.com" + +echo "OK" diff --git a/test/tls/CertDirScannerTest.cpp b/test/tls/CertDirScannerTest.cpp new file mode 100644 index 000000000..4b9fe2ae7 --- /dev/null +++ b/test/tls/CertDirScannerTest.cpp @@ -0,0 +1,270 @@ +/* + * Copyright (c) OpenMOQ contributors. + * This source code is licensed under the Apache 2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "tls/CertDirScanner.h" + +#include "CertTestUtils.h" +#include "tls/CertLoader.h" + +#include +#include + +namespace openmoq::moqx::tls { +namespace { + +using ::testing::Contains; +using ::testing::HasSubstr; +using ::testing::IsEmpty; +using ::testing::UnorderedElementsAre; + +using moqx::test::makeSelfSignedCertPem; +using moqx::test::TempDir; + +std::vector scanStrict(const TempDir& dir) { + std::vector warnings; + auto entries = scanCertDir(dir.path(), /*strict=*/true, warnings); + EXPECT_THAT(warnings, IsEmpty()); + return entries; +} + +TEST(CertDirScanner, EmptyDir) { + TempDir dir; + EXPECT_THAT(scanStrict(dir), IsEmpty()); +} + +TEST(CertDirScanner, PairsWithSanIdentities) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a.example.com", {"a.example.com", "alt.example.com"})); + writePair(dir, "b", makeSelfSignedCertPem("ignored-cn", {"b.example.com"})); + + auto entries = scanStrict(dir); + ASSERT_EQ(entries.size(), 2u); + // pemBases is sorted, so "a" precedes "b". + EXPECT_THAT(entries[0].identities, UnorderedElementsAre("a.example.com", "alt.example.com")); + // SANs are authoritative: the CN must not appear when SANs exist. + EXPECT_THAT(entries[1].identities, UnorderedElementsAre("b.example.com")); + // The CN is still recorded as the primary identity (ticket resumption key). + EXPECT_EQ(entries[1].primaryIdentity, "ignored-cn"); + EXPECT_THAT(entries[0].certPath, HasSubstr("a.pem")); + EXPECT_THAT(entries[0].keyPath, HasSubstr("a.key")); +} + +TEST(CertDirScanner, SanOnlyCertWithoutCnGetsSubjectPrimaryIdentity) { + TempDir dir; + auto pair = makeSelfSignedCertPem("", {"a.example.com"}); + writePair(dir, "a", pair); + + auto entries = scanStrict(dir); + ASSERT_EQ(entries.size(), 1u); + // The resumption key must equal what fizz stores in the ticket: + // SelfCert::getIdentity(), which is the subject DN when there is no CN. + auto cert = makeSelfCertFromPems(pair.certPem, pair.keyPem, "(test)"); + EXPECT_FALSE(entries[0].primaryIdentity.empty()); + EXPECT_EQ(entries[0].primaryIdentity, normalizeLookupKey(cert->getIdentity())); +} + +TEST(CertDirScanner, CnFallbackWhenNoSans) { + TempDir dir; + writePair(dir, "only-cn", makeSelfSignedCertPem("cn.example.com")); + + auto entries = scanStrict(dir); + ASSERT_EQ(entries.size(), 1u); + EXPECT_THAT(entries[0].identities, UnorderedElementsAre("cn.example.com")); +} + +TEST(CertDirScanner, IdentitiesLowercased) { + TempDir dir; + writePair(dir, "upper", makeSelfSignedCertPem("MiXeD.ExAmPlE.CoM")); + + auto entries = scanStrict(dir); + ASSERT_EQ(entries.size(), 1u); + EXPECT_THAT(entries[0].identities, UnorderedElementsAre("mixed.example.com")); +} + +TEST(CertDirScanner, WildcardNormalized) { + TempDir dir; + writePair(dir, "wild", makeSelfSignedCertPem("*.example.com", {"*.example.com"})); + + auto entries = scanStrict(dir); + ASSERT_EQ(entries.size(), 1u); + EXPECT_THAT(entries[0].identities, UnorderedElementsAre(".example.com")); +} + +TEST(CertDirScanner, NestedWildcardRejected) { + TempDir dir; + writePair(dir, "bad", makeSelfSignedCertPem("*.a.example.com", {"*.*.example.com"})); + + std::vector warnings; + EXPECT_THROW(scanCertDir(dir.path(), true, warnings), std::runtime_error); + + warnings.clear(); + auto entries = scanCertDir(dir.path(), false, warnings); + EXPECT_THAT(entries, IsEmpty()); + EXPECT_THAT(warnings, Contains(HasSubstr("invalid identity"))); +} + +TEST(CertDirScanner, OrphanPemStrictThrows) { + TempDir dir; + dir.writeFile("lonely.pem", makeSelfSignedCertPem("x.example.com").certPem); + + std::vector warnings; + EXPECT_THROW(scanCertDir(dir.path(), true, warnings), std::runtime_error); + + warnings.clear(); + auto entries = scanCertDir(dir.path(), false, warnings); + EXPECT_THAT(entries, IsEmpty()); + EXPECT_THAT(warnings, Contains(HasSubstr("orphan cert"))); +} + +TEST(CertDirScanner, OrphanKeyStrictThrows) { + TempDir dir; + dir.writeFile("lonely.key", makeSelfSignedCertPem("x.example.com").keyPem); + + std::vector warnings; + EXPECT_THROW(scanCertDir(dir.path(), true, warnings), std::runtime_error); + + warnings.clear(); + auto entries = scanCertDir(dir.path(), false, warnings); + EXPECT_THAT(entries, IsEmpty()); + EXPECT_THAT(warnings, Contains(HasSubstr("orphan key"))); +} + +TEST(CertDirScanner, GarbageCertRejected) { + TempDir dir; + auto good = makeSelfSignedCertPem("good.example.com"); + writePair(dir, "good", good); + dir.writeFile("bad.pem", "not a certificate"); + dir.writeFile("bad.key", good.keyPem); + + std::vector warnings; + EXPECT_THROW(scanCertDir(dir.path(), true, warnings), std::runtime_error); + + warnings.clear(); + auto entries = scanCertDir(dir.path(), false, warnings); + ASSERT_EQ(entries.size(), 1u); + EXPECT_THAT(entries[0].identities, UnorderedElementsAre("good.example.com")); + EXPECT_THAT(warnings, Contains(HasSubstr("bad.pem"))); +} + +TEST(CertDirScanner, DuplicateIdentityNamesBothFiles) { + TempDir dir; + writePair(dir, "first", makeSelfSignedCertPem("dup.example.com")); + writePair(dir, "second", makeSelfSignedCertPem("dup.example.com")); + + std::vector warnings; + try { + scanCertDir(dir.path(), true, warnings); + FAIL() << "expected duplicate identity to throw"; + } catch (const std::runtime_error& e) { + EXPECT_THAT(e.what(), HasSubstr("first.pem")); + EXPECT_THAT(e.what(), HasSubstr("second.pem")); + EXPECT_THAT(e.what(), HasSubstr("dup.example.com")); + } + + // Non-strict: first claimant (sorted order) wins. + auto entries = scanCertDir(dir.path(), false, warnings); + ASSERT_EQ(entries.size(), 1u); + EXPECT_THAT(entries[0].certPath, HasSubstr("first.pem")); +} + +TEST(CertDirScanner, IgnoresOtherExtensions) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a.example.com")); + dir.writeFile("README.md", "docs"); + dir.writeFile("bundle.p12", "binary"); + + auto entries = scanStrict(dir); + EXPECT_EQ(entries.size(), 1u); +} + +// --- previous-scan reuse --- + +// Guarantee a strictly newer mtime than a previous write of the same path. +void bumpMtime(const TempDir& dir, const std::string& filename) { + namespace fs = std::filesystem; + auto p = std::filesystem::path(dir.path()) / filename; + fs::last_write_time(p, fs::last_write_time(p) + std::chrono::seconds(2)); +} + +TEST(CertDirScanner, PreviousEntryReusedWhenMtimesUnchanged) { + namespace fs = std::filesystem; + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a.example.com")); + auto previous = scanStrict(dir); + ASSERT_EQ(previous.size(), 1u); + + // Overwrite with garbage but restore the mtime: a scan passing `previous` + // must copy the entry forward without reading the file. + auto pemPath = fs::path(dir.path()) / "a.pem"; + auto mtime = fs::last_write_time(pemPath); + dir.writeFile("a.pem", "not a certificate"); + fs::last_write_time(pemPath, mtime); + + std::vector warnings; + auto entries = scanCertDir(dir.path(), /*strict=*/false, warnings, &previous); + EXPECT_THAT(warnings, IsEmpty()); + ASSERT_EQ(entries.size(), 1u); + EXPECT_EQ(entries[0].identities, previous[0].identities); + EXPECT_EQ(entries[0].primaryIdentity, previous[0].primaryIdentity); +} + +TEST(CertDirScanner, PreviousEntryReparsedWhenMtimeChanges) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a.example.com")); + auto previous = scanStrict(dir); + + dir.writeFile("a.pem", "not a certificate"); + bumpMtime(dir, "a.pem"); + + std::vector warnings; + auto entries = scanCertDir(dir.path(), /*strict=*/false, warnings, &previous); + EXPECT_THAT(entries, IsEmpty()); + EXPECT_THAT(warnings, Contains(HasSubstr("a.pem"))); +} + +TEST(CertDirScanner, PreviousEntriesStillTripDuplicateDetection) { + TempDir dir; + writePair(dir, "second", makeSelfSignedCertPem("dup.example.com")); + auto previous = scanStrict(dir); + + // "first" sorts before "second", but the incumbent claims its identity + // ahead of any newcomer; the arrival is dropped with a duplicate warning. + writePair(dir, "first", makeSelfSignedCertPem("dup.example.com")); + + std::vector warnings; + auto entries = scanCertDir(dir.path(), /*strict=*/false, warnings, &previous); + ASSERT_EQ(entries.size(), 1u); + EXPECT_THAT(entries[0].certPath, HasSubstr("second.pem")); + EXPECT_THAT(warnings, Contains(HasSubstr("duplicate identity"))); +} + +TEST(CertDirScanner, PreviousEntryForRemovedFileIsIgnored) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a.example.com")); + writePair(dir, "b", makeSelfSignedCertPem("b.example.com")); + auto previous = scanStrict(dir); + ASSERT_EQ(previous.size(), 2u); + + dir.removeFile("b.pem"); + dir.removeFile("b.key"); + + std::vector warnings; + auto entries = scanCertDir(dir.path(), /*strict=*/false, warnings, &previous); + EXPECT_THAT(warnings, IsEmpty()); + ASSERT_EQ(entries.size(), 1u); + EXPECT_THAT(entries[0].identities, UnorderedElementsAre("a.example.com")); +} + +TEST(CertDirScanner, UnreadableDir) { + std::vector warnings; + EXPECT_THROW(scanCertDir("/nonexistent/moqx-scanner-test", true, warnings), std::runtime_error); + // Non-strict too: an unopenable dir is a scan-level failure (indistinguishable + // from an empty dir), so the caller must keep its previous state. + EXPECT_THROW(scanCertDir("/nonexistent/moqx-scanner-test", false, warnings), std::runtime_error); +} + +} // namespace +} // namespace openmoq::moqx::tls diff --git a/test/tls/CertTestUtils.h b/test/tls/CertTestUtils.h new file mode 100644 index 000000000..8f47e435a --- /dev/null +++ b/test/tls/CertTestUtils.h @@ -0,0 +1,154 @@ +/* + * Copyright (c) OpenMOQ contributors. + * This source code is licensed under the Apache 2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "../util/TempDir.h" + +namespace openmoq::moqx::test { + +// EC256 is the default: keygen is orders of magnitude faster than RSA. +enum class TestKeyType { EC256, RSA2048 }; + +// Self-signed cert + key as raw OpenSSL handles; the caller owns both frees. +// `sanDns` entries become DNS SANs (leading wildcard label allowed); empty +// means no SAN extension. An empty `cn` omits the CN attribute entirely. +// Throws std::runtime_error on failure. +inline std::pair makeSelfSignedCert( + const std::string& cn, + const std::vector& sanDns = {}, + TestKeyType keyType = TestKeyType::EC256 +) { + EVP_PKEY* pkey = keyType == TestKeyType::RSA2048 ? EVP_RSA_gen(2048) : EVP_EC_gen("P-256"); + if (!pkey) { + throw std::runtime_error("key generation failed"); + } + X509* x509 = X509_new(); + if (!x509) { + EVP_PKEY_free(pkey); + throw std::runtime_error("X509_new failed"); + } + bool ok = true; + ASN1_INTEGER_set(X509_get_serialNumber(x509), 1); + X509_gmtime_adj(X509_getm_notBefore(x509), 0); + X509_gmtime_adj(X509_getm_notAfter(x509), 3600); + X509_set_pubkey(x509, pkey); + X509_NAME* name = X509_get_subject_name(x509); + if (!cn.empty()) { + X509_NAME_add_entry_by_txt( + name, + "CN", + MBSTRING_ASC, + reinterpret_cast(cn.c_str()), + -1, + -1, + 0 + ); + } else { + // A DN with no attributes at all makes some parsers unhappy; give the + // CN-less cert an O attribute so the subject stays non-empty. + X509_NAME_add_entry_by_txt( + name, + "O", + MBSTRING_ASC, + reinterpret_cast("moqx-test-no-cn"), + -1, + -1, + 0 + ); + } + X509_set_issuer_name(x509, name); // self-signed + + if (!sanDns.empty()) { + std::string sanValue; + for (const auto& dns : sanDns) { + if (!sanValue.empty()) { + sanValue += ","; + } + sanValue += "DNS:" + dns; + } + X509_EXTENSION* ext = + X509V3_EXT_conf_nid(nullptr, nullptr, NID_subject_alt_name, sanValue.c_str()); + if (ext) { + X509_add_ext(x509, ext, -1); + X509_EXTENSION_free(ext); + } else { + ok = false; + } + } + + ok = ok && X509_sign(x509, pkey, EVP_sha256()) > 0; + if (!ok) { + X509_free(x509); + EVP_PKEY_free(pkey); + throw std::runtime_error("failed to build self-signed cert"); + } + return {pkey, x509}; +} + +struct PemPair { + std::string certPem; + std::string keyPem; +}; + +// PEM-encoded self-signed cert + unencrypted private key. +inline PemPair makeSelfSignedCertPem( + const std::string& cn, + const std::vector& sanDns = {}, + TestKeyType keyType = TestKeyType::EC256 +) { + auto [pkey, x509] = makeSelfSignedCert(cn, sanDns, keyType); + + PemPair out; + bool ok = false; + BIO* bio = BIO_new(BIO_s_mem()); + if (bio) { + ok = PEM_write_bio_X509(bio, x509) == 1; + if (ok) { + char* data = nullptr; + long len = BIO_get_mem_data(bio, &data); + out.certPem.assign(data, static_cast(len)); + } + BIO_free(bio); + } + bio = ok ? BIO_new(BIO_s_mem()) : nullptr; + ok = false; + if (bio) { + ok = PEM_write_bio_PrivateKey(bio, pkey, nullptr, nullptr, 0, nullptr, nullptr) == 1; + if (ok) { + char* data = nullptr; + long len = BIO_get_mem_data(bio, &data); + out.keyPem.assign(data, static_cast(len)); + } + BIO_free(bio); + } + + X509_free(x509); + EVP_PKEY_free(pkey); + if (!ok) { + throw std::runtime_error("failed to PEM-encode cert/key"); + } + return out; +} + +// Write a cert/key pair as .pem + .key into the directory. +inline void writePair(const TempDir& dir, const std::string& base, const PemPair& pair) { + dir.writeFile(base + ".pem", pair.certPem); + dir.writeFile(base + ".key", pair.keyPem); +} + +} // namespace openmoq::moqx::test diff --git a/test/tls/FizzContextBuilderTest.cpp b/test/tls/FizzContextBuilderTest.cpp new file mode 100644 index 000000000..6b7de5824 --- /dev/null +++ b/test/tls/FizzContextBuilderTest.cpp @@ -0,0 +1,81 @@ +/* + * Copyright (c) OpenMOQ contributors. + * This source code is licensed under the Apache 2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "tls/FizzContextBuilder.h" + +#include "CertTestUtils.h" + +#include + +namespace openmoq::moqx::tls { +namespace { + +using moqx::test::makeSelfSignedCertPem; +using moqx::test::TempDir; + +// Single-cert listener config over a freshly written pair in `dir`. +config::ListenerTlsConfig fileConfig(const TempDir& dir) { + writePair(dir, "a", makeSelfSignedCertPem("a.example.com")); + config::ListenerTlsConfig cfg; + cfg.tls.certFile = dir.path() + "/a.pem"; + cfg.tls.keyFile = dir.path() + "/a.key"; + return cfg; +} + +TEST(FizzContextBuilder, ConfiguredTicketSeedsAccepted) { + TempDir dir; + auto cfg = fileConfig(dir); + // Two seeds: 32-byte minimum and a longer one; first-encrypts semantics are + // fizz's, this exercises the plumbing and the accepted-length path. + cfg.ticketSeeds = {std::string(32, 'x'), std::string(48, 'y')}; + + auto ctx = buildFizzServerContext(config::TlsMode{cfg}, {.alpns = {"moq-00"}}); + ASSERT_NE(ctx, nullptr); + EXPECT_NE(ctx->getTicketCipher(), nullptr); +} + +TEST(FizzContextBuilder, ShortTicketSeedThrows) { + TempDir dir; + auto cfg = fileConfig(dir); + cfg.ticketSeeds = {std::string(16, 'x')}; + + // fizz rejects sub-32-byte secrets wholesale; surfacing that as an error + // beats a silently secretless ticket cipher (no tickets, no resumption). + EXPECT_THROW( + buildFizzServerContext(config::TlsMode{cfg}, {.alpns = {"moq-00"}}), + std::runtime_error + ); +} + +TEST(FizzContextBuilder, NoSeedsStillBuildsTicketCipher) { + TempDir dir; + auto cfg = fileConfig(dir); + + auto ctx = buildFizzServerContext(config::TlsMode{cfg}, {.alpns = {"moq-00"}}); + ASSERT_NE(ctx, nullptr); + EXPECT_NE(ctx->getTicketCipher(), nullptr); +} + +TEST(FizzContextBuilder, ListenersSharingCertDirShareManager) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a.example.com")); + config::ListenerTlsConfig cfg; + cfg.certDir = config::CertDirConfig{dir.path(), std::chrono::seconds(0)}; + + auto first = makeCertManager(cfg); + auto second = makeCertManager(cfg); + // Same cert source, one manager: one scan, one rescan thread, one key set. + EXPECT_EQ(first.get(), second.get()); + + TempDir otherDir; + writePair(otherDir, "b", makeSelfSignedCertPem("b.example.com")); + config::ListenerTlsConfig otherCfg; + otherCfg.certDir = config::CertDirConfig{otherDir.path(), std::chrono::seconds(0)}; + EXPECT_NE(makeCertManager(otherCfg).get(), first.get()); +} + +} // namespace +} // namespace openmoq::moqx::tls diff --git a/test/tls/SniCertManagerTest.cpp b/test/tls/SniCertManagerTest.cpp new file mode 100644 index 000000000..cb1eb8d19 --- /dev/null +++ b/test/tls/SniCertManagerTest.cpp @@ -0,0 +1,643 @@ +/* + * Copyright (c) OpenMOQ contributors. + * This source code is licensed under the Apache 2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "tls/SniCertManager.h" + +#include "CertTestUtils.h" +#include "tls/CertLoader.h" + +#include +#include +#include + +namespace openmoq::moqx::tls { +namespace { + +using ::testing::HasSubstr; + +using moqx::test::makeSelfSignedCertPem; +using moqx::test::TempDir; +using moqx::test::TestKeyType; + +const std::vector kAllSchemes = { + fizz::SignatureScheme::ecdsa_secp256r1_sha256, + fizz::SignatureScheme::rsa_pss_sha256, +}; + +SniCertManager::Options optionsFor(const TempDir& dir, std::chrono::seconds interval = {}) { + SniCertManager::Options options; + options.certDir = config::CertDirConfig{dir.path(), interval}; + return options; +} + +// SNI lookup with every scheme supported on both sides; returns folly::none on +// a miss. +fizz::CertMatch match(const SniCertManager& manager, folly::Optional sni) { + fizz::CertMatch ret; + fizz::Error err; + fizz::ClientHello chlo; + EXPECT_EQ(manager.getCert(ret, err, sni, kAllSchemes, kAllSchemes, chlo), fizz::Status::Success); + return ret; +} + +std::string identityOf(const fizz::CertMatch& result) { + if (!result) { + return ""; + } + return result->cert->getIdentity(); +} + +// Guarantee a strictly newer mtime than a previous write of the same path. +void bumpMtime(const TempDir& dir, const std::string& filename) { + namespace fs = std::filesystem; + auto p = fs::path(dir.path()) / filename; + fs::last_write_time(p, fs::last_write_time(p) + std::chrono::seconds(2)); +} + +TEST(SniCertManager, ExactMatch) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a.example.com")); + writePair(dir, "b", makeSelfSignedCertPem("b.example.com")); + SniCertManager manager(optionsFor(dir)); + + auto result = match(manager, std::string("a.example.com")); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(identityOf(result), "a.example.com"); + EXPECT_EQ(result->type, fizz::MatchType::Direct); + EXPECT_EQ(identityOf(match(manager, std::string("b.example.com"))), "b.example.com"); +} + +TEST(SniCertManager, CaseInsensitive) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a.example.com")); + SniCertManager manager(optionsFor(dir)); + + EXPECT_EQ(identityOf(match(manager, std::string("A.EXAMPLE.com"))), "a.example.com"); +} + +TEST(SniCertManager, WildcardMatchesOneLabel) { + TempDir dir; + writePair(dir, "wild", makeSelfSignedCertPem("*.example.com", {"*.example.com"})); + SniCertManager manager(optionsFor(dir)); + + EXPECT_EQ(identityOf(match(manager, std::string("a.example.com"))), "*.example.com"); + // Two labels deep: ".a.example.com" is not a stored key. + EXPECT_FALSE(match(manager, std::string("b.a.example.com")).has_value()); + // The bare zone doesn't match "*.example.com" either. + EXPECT_FALSE(match(manager, std::string("example.com")).has_value()); +} + +TEST(SniCertManager, SniKeepsALeadingStar) { + TempDir dir; + writePair(dir, "foo", makeSelfSignedCertPem("foo.example.com")); + writePair(dir, "wild", makeSelfSignedCertPem("*.example.com", {"*.example.com"})); + SniCertManager manager(optionsFor(dir)); + + // fizz lowercases the SNI and nothing else; stripping a leading '*' is the + // insertion-side rule. So this misses the exact key and falls through to the + // first-dot wildcard instead of hitting "foo.example.com". + EXPECT_EQ(identityOf(match(manager, std::string("*foo.example.com"))), "*.example.com"); +} + +TEST(SniCertManager, NoMatchNoFallbackReturnsNone) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a.example.com")); + SniCertManager manager(optionsFor(dir)); + + EXPECT_FALSE(match(manager, std::string("unknown.example.com")).has_value()); + EXPECT_FALSE(match(manager, folly::none).has_value()); +} + +TEST(SniCertManager, FallbackServesUnmatchedAndAbsentSni) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a.example.com")); + auto fallbackPem = makeSelfSignedCertPem("fallback.example.com"); + TempDir fallbackDir; + writePair(fallbackDir, "fb", fallbackPem); + + auto options = optionsFor(dir); + options.fallbackCertFile = fallbackDir.path() + "/fb.pem"; + options.fallbackKeyFile = fallbackDir.path() + "/fb.key"; + SniCertManager manager(std::move(options)); + + auto unmatched = match(manager, std::string("unknown.example.com")); + ASSERT_TRUE(unmatched.has_value()); + EXPECT_EQ(identityOf(unmatched), "fallback.example.com"); + EXPECT_EQ(unmatched->type, fizz::MatchType::Default); + + auto absent = match(manager, folly::none); + ASSERT_TRUE(absent.has_value()); + EXPECT_EQ(identityOf(absent), "fallback.example.com"); + + // SNI hits still take precedence over the fallback. + EXPECT_EQ(identityOf(match(manager, std::string("a.example.com"))), "a.example.com"); +} + +TEST(SniCertManager, MaterialFallback) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a.example.com")); + auto fallbackPem = makeSelfSignedCertPem("mat.example.com"); + + auto options = optionsFor(dir); + options.fallbackMaterial = config::TlsMaterial{fallbackPem.certPem, fallbackPem.keyPem}; + SniCertManager manager(std::move(options)); + + EXPECT_EQ(identityOf(match(manager, folly::none)), "mat.example.com"); +} + +TEST(SniCertManager, EmptyDirNoFallbackThrows) { + TempDir dir; + try { + SniCertManager manager(optionsFor(dir)); + FAIL() << "expected empty cert_dir with no fallback to throw"; + } catch (const std::runtime_error& e) { + EXPECT_THAT(e.what(), HasSubstr("no fallback")); + } +} + +TEST(SniCertManager, EmptyDirWithFallbackServes) { + TempDir dir; + TempDir fallbackDir; + writePair(fallbackDir, "fb", makeSelfSignedCertPem("fallback.example.com")); + + auto options = optionsFor(dir); + options.fallbackCertFile = fallbackDir.path() + "/fb.pem"; + options.fallbackKeyFile = fallbackDir.path() + "/fb.key"; + SniCertManager manager(std::move(options)); + + EXPECT_EQ(identityOf(match(manager, std::string("anything"))), "fallback.example.com"); +} + +TEST(SniCertManager, StrictStartupScanThrows) { + TempDir dir; + dir.writeFile("orphan.pem", makeSelfSignedCertPem("x.example.com").certPem); + EXPECT_THROW(SniCertManager{optionsFor(dir)}, std::runtime_error); +} + +TEST(SniCertManager, ServingSurvivesFileDeletion) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a.example.com")); + SniCertManager manager(optionsFor(dir)); + + // The constructor loaded the pair, so handshakes never touch the files. + dir.removeFile("a.pem"); + dir.removeFile("a.key"); + EXPECT_EQ(identityOf(match(manager, std::string("a.example.com"))), "a.example.com"); +} + +TEST(SniCertManager, BrokenKeyIsFatalAtStartup) { + TempDir dir; + auto pairA = makeSelfSignedCertPem("a.example.com"); + auto pairB = makeSelfSignedCertPem("b.example.com"); + // a.pem paired with b's key: the scan passes (the cert parses), the load + // does not. Startup loads every pair, so this cannot reach a handshake. + dir.writeFile("a.pem", pairA.certPem); + dir.writeFile("a.key", pairB.keyPem); + TempDir fallbackDir; + writePair(fallbackDir, "fb", makeSelfSignedCertPem("fallback.example.com")); + + auto options = optionsFor(dir); + options.fallbackCertFile = fallbackDir.path() + "/fb.pem"; + options.fallbackKeyFile = fallbackDir.path() + "/fb.key"; + EXPECT_THROW(SniCertManager{std::move(options)}, std::runtime_error); +} + +TEST(SniCertManager, SchemeSelectionPrefersPeerIntersection) { + TempDir dir; + writePair(dir, "ec", makeSelfSignedCertPem("ec.example.com", {}, TestKeyType::EC256)); + writePair(dir, "rsa", makeSelfSignedCertPem("rsa.example.com", {}, TestKeyType::RSA2048)); + SniCertManager manager(optionsFor(dir)); + + fizz::CertMatch ret; + fizz::Error err; + fizz::ClientHello chlo; + + // Peer speaks only ECDSA: the EC cert negotiates that scheme. + std::vector ecOnly = {fizz::SignatureScheme::ecdsa_secp256r1_sha256}; + ASSERT_EQ( + manager.getCert(ret, err, std::string("ec.example.com"), kAllSchemes, ecOnly, chlo), + fizz::Status::Success + ); + ASSERT_TRUE(ret.has_value()); + EXPECT_EQ(ret->scheme, fizz::SignatureScheme::ecdsa_secp256r1_sha256); + + // Peer speaks only ECDSA but SNI selects the RSA cert: per the CertManager + // contract peer schemes are ignored rather than failing the match. + ASSERT_EQ( + manager.getCert(ret, err, std::string("rsa.example.com"), kAllSchemes, ecOnly, chlo), + fizz::Status::Success + ); + ASSERT_TRUE(ret.has_value()); + EXPECT_EQ(ret->scheme, fizz::SignatureScheme::rsa_pss_sha256); + + // Server itself doesn't support RSA schemes: the RSA cert is unusable. + std::vector serverEcOnly = {fizz::SignatureScheme::ecdsa_secp256r1_sha256}; + ASSERT_EQ( + manager.getCert(ret, err, std::string("rsa.example.com"), serverEcOnly, kAllSchemes, chlo), + fizz::Status::Success + ); + EXPECT_FALSE(ret.has_value()); +} + +TEST(SniCertManager, SchemeSelectionFollowsAReloadedKeyType) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a.example.com", {}, TestKeyType::EC256)); + SniCertManager manager(optionsFor(dir)); + EXPECT_EQ( + match(manager, std::string("a.example.com"))->scheme, + fizz::SignatureScheme::ecdsa_secp256r1_sha256 + ); + + // Rotate the pair EC -> RSA. The negotiated scheme must come from the + // reloaded cert: a scheme the served key cannot produce would fail every + // handshake for the identity. + writePair(dir, "a", makeSelfSignedCertPem("a.example.com", {}, TestKeyType::RSA2048)); + bumpMtime(dir, "a.pem"); + manager.rescan(); + + auto result = match(manager, std::string("a.example.com")); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->scheme, fizz::SignatureScheme::rsa_pss_sha256); +} + +TEST(SniCertManager, ResumptionLooksUpPrimaryIdentityCn) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("relay-cn-a", {"a.example.com"})); + SniCertManager manager(optionsFor(dir)); + + // SNI matches via the SAN; the served identity is the CN. + EXPECT_EQ(identityOf(match(manager, std::string("a.example.com"))), "relay-cn-a"); + // Ticket resumption resolves the cert's primary identity (its CN). + // With SANs present, the CN is not among the SNI identities. + auto cert = manager.getCert(std::string("relay-cn-a")); + ASSERT_NE(cert, nullptr); + EXPECT_EQ(cert->getIdentity(), "relay-cn-a"); +} + +TEST(SniCertManager, SharedCnAcrossPairsIsNotADuplicate) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("shared-cn", {"a.example.com"})); + writePair(dir, "b", makeSelfSignedCertPem("shared-cn", {"b.example.com"})); + // A CN shared by several pairs must not trip duplicate-identity detection. + SniCertManager manager(optionsFor(dir)); + + EXPECT_EQ(identityOf(match(manager, std::string("a.example.com"))), "shared-cn"); + EXPECT_EQ(identityOf(match(manager, std::string("b.example.com"))), "shared-cn"); + EXPECT_NE(manager.getCert(std::string("shared-cn")), nullptr); +} + +TEST(SniCertManager, PeerSchemeMismatchPrefersUsableFallback) { + TempDir dir; + writePair(dir, "rsa", makeSelfSignedCertPem("rsa.example.com", {}, TestKeyType::RSA2048)); + TempDir fallbackDir; + writePair(fallbackDir, "fb", makeSelfSignedCertPem("fallback.example.com")); // EC256 + + auto options = optionsFor(dir); + options.fallbackCertFile = fallbackDir.path() + "/fb.pem"; + options.fallbackKeyFile = fallbackDir.path() + "/fb.key"; + SniCertManager manager(std::move(options)); + + // Peer only speaks ECDSA and the SNI-matched cert is RSA: the EC fallback + // (which the peer can verify) beats serving the RSA cert with a scheme the + // peer never advertised. + fizz::CertMatch ret; + fizz::Error err; + fizz::ClientHello chlo; + std::vector ecOnly = {fizz::SignatureScheme::ecdsa_secp256r1_sha256}; + ASSERT_EQ( + manager.getCert(ret, err, std::string("rsa.example.com"), kAllSchemes, ecOnly, chlo), + fizz::Status::Success + ); + ASSERT_TRUE(ret.has_value()); + EXPECT_EQ(identityOf(ret), "fallback.example.com"); + EXPECT_EQ(ret->scheme, fizz::SignatureScheme::ecdsa_secp256r1_sha256); +} + +TEST(SniCertManager, GetCertByIdentityForResumption) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a.example.com")); + TempDir fallbackDir; + writePair(fallbackDir, "fb", makeSelfSignedCertPem("fallback.example.com")); + + auto options = optionsFor(dir); + options.fallbackCertFile = fallbackDir.path() + "/fb.pem"; + options.fallbackKeyFile = fallbackDir.path() + "/fb.key"; + SniCertManager manager(std::move(options)); + + auto cert = manager.getCert(std::string("a.example.com")); + ASSERT_NE(cert, nullptr); + EXPECT_EQ(cert->getIdentity(), "a.example.com"); + + EXPECT_NE(manager.getCert(std::string("fallback.example.com")), nullptr); + EXPECT_EQ(manager.getCert(std::string("removed.example.com")), nullptr); +} + +TEST(SniCertManager, GetCertByIdentityFallsBackWhenRescanDropsThePair) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a.example.com")); + TempDir fallbackDir; + writePair(fallbackDir, "fb", makeSelfSignedCertPem("fb.example.com")); + + auto options = optionsFor(dir); + options.fallbackCertFile = fallbackDir.path() + "/fb.pem"; + options.fallbackKeyFile = fallbackDir.path() + "/fb.key"; + SniCertManager manager(std::move(options)); + + // A pair that appears already broken has no previous version to keep, so + // the rescan drops it. Resumption must still reach the fallback, matching + // what a fresh handshake for the same SNI gets. + auto pair = makeSelfSignedCertPem("fb.example.com"); + auto other = makeSelfSignedCertPem("other.example.com"); + dir.writeFile("fb.pem", pair.certPem); + dir.writeFile("fb.key", other.keyPem); + manager.rescan(); + + auto cert = manager.getCert(std::string("fb.example.com")); + ASSERT_NE(cert, nullptr); + EXPECT_EQ(cert->getIdentity(), "fb.example.com"); + EXPECT_EQ(identityOf(match(manager, std::string("fb.example.com"))), "fb.example.com"); +} + +// --- rescan (driven directly; no timer involved) --- + +TEST(SniCertManager, RescanPicksUpAddedPair) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a.example.com")); + SniCertManager manager(optionsFor(dir)); + + EXPECT_FALSE(match(manager, std::string("new.example.com")).has_value()); + writePair(dir, "new", makeSelfSignedCertPem("new.example.com")); + manager.rescan(); + EXPECT_EQ(identityOf(match(manager, std::string("new.example.com"))), "new.example.com"); +} + +TEST(SniCertManager, RescanDropsRemovedPair) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a.example.com")); + writePair(dir, "b", makeSelfSignedCertPem("b.example.com")); + SniCertManager manager(optionsFor(dir)); + + dir.removeFile("b.pem"); + dir.removeFile("b.key"); + manager.rescan(); + EXPECT_FALSE(match(manager, std::string("b.example.com")).has_value()); + EXPECT_EQ(identityOf(match(manager, std::string("a.example.com"))), "a.example.com"); +} + +TEST(SniCertManager, RescanDropsPairWhenOnlyTheKeyIsRemoved) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a.example.com")); + writePair(dir, "b", makeSelfSignedCertPem("b.example.com")); + SniCertManager manager(optionsFor(dir)); + + // The scan rejects the orphan .pem on every pass, so retaining it would + // serve b forever: deleting the key must retire the pair. + dir.removeFile("b.key"); + manager.rescan(); + EXPECT_FALSE(match(manager, std::string("b.example.com")).has_value()); + manager.rescan(); + EXPECT_FALSE(match(manager, std::string("b.example.com")).has_value()); + EXPECT_EQ(identityOf(match(manager, std::string("a.example.com"))), "a.example.com"); +} + +TEST(SniCertManager, RescanReloadsRewrittenPair) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a.example.com")); + SniCertManager manager(optionsFor(dir)); + EXPECT_EQ(identityOf(match(manager, std::string("a.example.com"))), "a.example.com"); + + // Same identity, fresh key material; SANs move to a second name to make the + // reload observable. + writePair(dir, "a", makeSelfSignedCertPem("a.example.com", {"a.example.com", "a2.example.com"})); + bumpMtime(dir, "a.pem"); + manager.rescan(); + + EXPECT_EQ(identityOf(match(manager, std::string("a2.example.com"))), "a.example.com"); +} + +TEST(SniCertManager, RescanKeepsLoadedCertForUnchangedPair) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a.example.com")); + SniCertManager manager(optionsFor(dir)); + + auto before = match(manager, std::string("a.example.com")); + manager.rescan(); + auto after = match(manager, std::string("a.example.com")); + ASSERT_TRUE(before.has_value()); + ASSERT_TRUE(after.has_value()); + // Same SelfCert instance: the loaded cert was carried across the rescan. + EXPECT_EQ(before->cert.get(), after->cert.get()); +} + +TEST(SniCertManager, RescanBrokenRewriteKeepsServingLastGoodCert) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a.example.com")); + TempDir fallbackDir; + writePair(fallbackDir, "fb", makeSelfSignedCertPem("fallback.example.com")); + + auto options = optionsFor(dir); + options.fallbackCertFile = fallbackDir.path() + "/fb.pem"; + options.fallbackKeyFile = fallbackDir.path() + "/fb.key"; + SniCertManager manager(std::move(options)); + EXPECT_EQ(identityOf(match(manager, std::string("a.example.com"))), "a.example.com"); + + // Rewrite with a mismatched key: the scan still indexes the pair (the cert + // parses) and the rescan's load fails — the previously loaded cert keeps + // serving, not the wrong-name fallback. + auto other = makeSelfSignedCertPem("other.example.com"); + dir.writeFile("a.key", other.keyPem); + bumpMtime(dir, "a.key"); + manager.rescan(); + + EXPECT_EQ(identityOf(match(manager, std::string("a.example.com"))), "a.example.com"); +} + +TEST(SniCertManager, RescanKeepsPairWhenRewriteIsUnparsable) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a.example.com")); + SniCertManager manager(optionsFor(dir)); + + // Non-atomic rewrite caught mid-write: unparsable cert, file still exists. + // The rescan treats this as a scan error, not a removal; the loaded cert + // keeps serving. + dir.writeFile("a.pem", "not a certificate"); + bumpMtime(dir, "a.pem"); + manager.rescan(); + EXPECT_EQ(identityOf(match(manager, std::string("a.example.com"))), "a.example.com"); +} + +TEST(SniCertManager, RescanDropsNewPairThatFailsToLoad) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a.example.com")); + TempDir fallbackDir; + writePair(fallbackDir, "fb", makeSelfSignedCertPem("fallback.example.com")); + + auto options = optionsFor(dir); + options.fallbackCertFile = fallbackDir.path() + "/fb.pem"; + options.fallbackKeyFile = fallbackDir.path() + "/fb.key"; + SniCertManager manager(std::move(options)); + + // New pair, mismatched key: the cert parses so the scan indexes it, but the + // load fails and there is no previous version to keep. + auto good = makeSelfSignedCertPem("new.example.com"); + auto other = makeSelfSignedCertPem("other.example.com"); + dir.writeFile("new.pem", good.certPem); + dir.writeFile("new.key", other.keyPem); + manager.rescan(); + EXPECT_EQ(identityOf(match(manager, std::string("new.example.com"))), "fallback.example.com"); + + // Repairing it hands the identity over on the next rescan. + dir.writeFile("new.key", good.keyPem); + manager.rescan(); + EXPECT_EQ(identityOf(match(manager, std::string("new.example.com"))), "new.example.com"); +} + +TEST(SniCertManager, RescanIncumbentKeepsIdentityAgainstNewDuplicate) { + TempDir dir; + writePair(dir, "z", makeSelfSignedCertPem("z-cn", {"a.example.com"})); + SniCertManager manager(optionsFor(dir)); + EXPECT_EQ(identityOf(match(manager, std::string("a.example.com"))), "z-cn"); + + // A newcomer claiming a served identity must not steal it even though "a" + // sorts before "z"; the arrival is dropped with a duplicate warning. + writePair(dir, "a", makeSelfSignedCertPem("a-cn", {"a.example.com"})); + manager.rescan(); + EXPECT_EQ(identityOf(match(manager, std::string("a.example.com"))), "z-cn"); + + // Removing the incumbent hands the identity over on the next rescan. + dir.removeFile("z.pem"); + dir.removeFile("z.key"); + manager.rescan(); + EXPECT_EQ(identityOf(match(manager, std::string("a.example.com"))), "a-cn"); +} + +TEST(SniCertManager, RetainedStaleEntryDoesNotShadowALoadedPair) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a-cn", {"x.example.com"})); + writePair(dir, "b", makeSelfSignedCertPem("b-cn", {"y.example.com"})); + SniCertManager manager(optionsFor(dir)); + EXPECT_EQ(identityOf(match(manager, std::string("x.example.com"))), "a-cn"); + + // The pairs swap names in one rescan window, and "a" is caught mid-write + // with a key that no longer matches its cert. Its retained entry still + // carries "x.example.com", which "b" now legitimately serves. + dir.writeFile("a.pem", makeSelfSignedCertPem("a2-cn", {"y.example.com"}).certPem); + bumpMtime(dir, "a.pem"); + writePair(dir, "b", makeSelfSignedCertPem("b2-cn", {"x.example.com"})); + bumpMtime(dir, "b.pem"); + bumpMtime(dir, "b.key"); + manager.rescan(); + + EXPECT_EQ(identityOf(match(manager, std::string("x.example.com"))), "b2-cn"); + // "a" never loaded, so the name it was rewritten to claim is unserved. + EXPECT_FALSE(match(manager, std::string("y.example.com")).has_value()); +} + +TEST(SniCertManager, ResumptionResolvesSanOnlyCertWithoutCn) { + TempDir dir; + auto pair = makeSelfSignedCertPem("", {"a.example.com"}); + writePair(dir, "a", pair); + SniCertManager manager(optionsFor(dir)); + + // fizz stores getIdentity() (the subject DN for a CN-less cert) in the + // ticket; resumption must resolve it though it is no SNI identity. + auto expected = makeSelfCertFromPems(pair.certPem, pair.keyPem, "(test)")->getIdentity(); + auto cert = manager.getCert(expected); + ASSERT_NE(cert, nullptr); + EXPECT_EQ(cert->getIdentity(), expected); +} + +TEST(SniCertManager, SharedCnResumptionWinnerIsLowestCertPath) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("shared-cn", {"a.example.com"}, TestKeyType::EC256)); + writePair(dir, "b", makeSelfSignedCertPem("shared-cn", {"b.example.com"}, TestKeyType::RSA2048)); + SniCertManager manager(optionsFor(dir)); + + auto cert = manager.getCert(std::string("shared-cn")); + ASSERT_NE(cert, nullptr); + // Deterministic winner: lowest certPath ("a.pem"), whose key is EC. + EXPECT_EQ(cert->getSigSchemes().front(), fizz::SignatureScheme::ecdsa_secp256r1_sha256); +} + +TEST(SniCertManager, ResumptionPrefersPrimaryIdentityOverAnotherCertsSan) { + TempDir dir; + // Disjoint identity sets, so both pairs load: only b's CN collides with a + // SAN of a, and a CN with SANs present is no SNI identity of its own. + writePair(dir, "a", makeSelfSignedCertPem("a-cn", {"a.example.com", "b.example.com"})); + writePair(dir, "b", makeSelfSignedCertPem("b.example.com", {"c.example.com"})); + SniCertManager manager(optionsFor(dir)); + + // A ticket naming "b.example.com" was issued for b, whose getIdentity() it + // stores; a merely carries that name as a SAN. + auto cert = manager.getCert(std::string("b.example.com")); + ASSERT_NE(cert, nullptr); + EXPECT_EQ(cert->getIdentity(), "b.example.com"); + + // SNI for the same name still resolves to a, which serves it. + EXPECT_EQ(identityOf(match(manager, std::string("b.example.com"))), "a-cn"); +} + +TEST(SniCertManager, RescanRetriesLoadAfterRetainingStaleEntry) { + namespace fs = std::filesystem; + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("v1", {"a.example.com"})); + SniCertManager manager(optionsFor(dir)); + EXPECT_EQ(identityOf(match(manager, std::string("a.example.com"))), "v1"); + + // Break the key so the rescan retains the previous entry, which keeps the + // pre-break mtimes. + dir.writeFile("a.key", makeSelfSignedCertPem("other.example.com").keyPem); + bumpMtime(dir, "a.key"); + auto brokenMtime = fs::last_write_time(fs::path(dir.path()) / "a.key"); + manager.rescan(); + EXPECT_EQ(identityOf(match(manager, std::string("a.example.com"))), "v1"); + + // Repair with a new pair but leave the key's mtime at the broken value. The + // retained entry's stale mtimes must still register this as a change, + // otherwise the identity stays pinned to v1 forever. + writePair(dir, "a", makeSelfSignedCertPem("v2", {"a.example.com"})); + fs::last_write_time(fs::path(dir.path()) / "a.key", brokenMtime); + manager.rescan(); + EXPECT_EQ(identityOf(match(manager, std::string("a.example.com"))), "v2"); +} + +TEST(SniCertManager, RescanRefreshesFallbackPair) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a.example.com")); + TempDir fallbackDir; + writePair(fallbackDir, "fb", makeSelfSignedCertPem("fallback-v1.example.com")); + + auto options = optionsFor(dir); + options.fallbackCertFile = fallbackDir.path() + "/fb.pem"; + options.fallbackKeyFile = fallbackDir.path() + "/fb.key"; + SniCertManager manager(std::move(options)); + EXPECT_EQ(identityOf(match(manager, folly::none)), "fallback-v1.example.com"); + + writePair(fallbackDir, "fb", makeSelfSignedCertPem("fallback-v2.example.com")); + bumpMtime(fallbackDir, "fb.pem"); + manager.rescan(); + EXPECT_EQ(identityOf(match(manager, folly::none)), "fallback-v2.example.com"); +} + +TEST(SniCertManager, RescanEmptiedDirServesFallbackOnly) { + TempDir dir; + writePair(dir, "a", makeSelfSignedCertPem("a.example.com")); + TempDir fallbackDir; + writePair(fallbackDir, "fb", makeSelfSignedCertPem("fallback.example.com")); + + auto options = optionsFor(dir); + options.fallbackCertFile = fallbackDir.path() + "/fb.pem"; + options.fallbackKeyFile = fallbackDir.path() + "/fb.key"; + SniCertManager manager(std::move(options)); + + dir.removeFile("a.pem"); + dir.removeFile("a.key"); + manager.rescan(); + EXPECT_EQ(identityOf(match(manager, std::string("a.example.com"))), "fallback.example.com"); +} + +} // namespace +} // namespace openmoq::moqx::tls diff --git a/test/util/TempDir.h b/test/util/TempDir.h new file mode 100644 index 000000000..2cf22d61a --- /dev/null +++ b/test/util/TempDir.h @@ -0,0 +1,81 @@ +/* + * Copyright (c) OpenMOQ contributors. + * This source code is licensed under the Apache 2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +// folly::test::TemporaryDirectory/TemporaryFile were tried and rejected: they +// live in folly_testing_test_util, which prebuilt deps ship compiled against +// the build distro's boost (versioned boost::regex symbols), so linking fails +// on hosts whose boost version differs from the tarball's. + +#include +#include +#include +#include +#include +#include + +namespace openmoq::moqx::test { + +namespace detail { +// Unique per-process temp path; the counter is shared by all helpers here. +inline std::filesystem::path uniqueTempPath(const std::string& prefix, std::string_view suffix) { + static std::atomic counter{0}; + return std::filesystem::temp_directory_path() / (prefix + std::to_string(::getpid()) + "_" + + std::to_string(counter++) + std::string(suffix)); +} +} // namespace detail + +// RAII temp file: bytes written to a unique path with the given suffix, +// removed on destruction. +class TempFile { +public: + TempFile(std::string_view bytes, std::string_view suffix) + : path_(detail::uniqueTempPath("moqx_test_", suffix)) { + std::ofstream ofs(path_, std::ios::binary); + ofs.write(bytes.data(), static_cast(bytes.size())); + } + ~TempFile() { + std::error_code ec; + std::filesystem::remove(path_, ec); + } + TempFile(const TempFile&) = delete; + TempFile& operator=(const TempFile&) = delete; + + std::string path() const { return path_.string(); } + +private: + std::filesystem::path path_; +}; + +// RAII temp directory: unique path, recursively removed on destruction. +class TempDir { +public: + TempDir() : path_(detail::uniqueTempPath("moqx_test_dir_", "")) { + std::filesystem::create_directories(path_); + } + ~TempDir() { + std::error_code ec; + std::filesystem::remove_all(path_, ec); + } + TempDir(const TempDir&) = delete; + TempDir& operator=(const TempDir&) = delete; + + std::string path() const { return path_.string(); } + + std::string writeFile(const std::string& filename, const std::string& bytes) const { + auto p = path_ / filename; + std::ofstream ofs(p, std::ios::binary); + ofs.write(bytes.data(), static_cast(bytes.size())); + return p.string(); + } + void removeFile(const std::string& filename) const { std::filesystem::remove(path_ / filename); } + +private: + std::filesystem::path path_; +}; + +} // namespace openmoq::moqx::test