diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 8300404b78d4c..c093cee075a60 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -64,7 +64,7 @@ runs: python3 -m pip install --upgrade pip if ${{ inputs.type == 'build' }} ; then - pip3 install pyyaml requests six + pip3 install pyyaml requests six cryptography else pip3 install pyright ruff fi diff --git a/Services/RequestServer/AIA.cpp b/Services/RequestServer/AIA.cpp new file mode 100644 index 0000000000000..c3bf139bc2035 --- /dev/null +++ b/Services/RequestServer/AIA.cpp @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2026-present, the Ladybird developers. + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace RequestServer { + +// This max was chosen to align with the (telemetry-tuned) value Chromium uses. +static constexpr size_t max_ca_issuers_urls_per_certificate = 5; + +// Upper bound on the process-wide fetched-intermediate cache — so a long-lived process that visits many misconfigured +// sites can't grow it without limit. +static constexpr size_t max_cached_intermediates = 256; + +// How long a caIssuers URL whose fetch failed is remembered, before we're willing to try it again. +static constexpr auto failed_url_retry_interval = AK::Duration::from_seconds(300); + +// Upper bound on the failed-URL negative cache, so a page hitting many distinct dead caIssuers URLs can't grow it +// without limit. +static constexpr size_t max_failed_urls = 1024; + +// Collect the caIssuers URLs from a cert's AIA extension. Only http URLs are returned; fetching an intermediate over +// https would itself require cert verification — risking infinite recursion. +Vector ca_issuers_urls(X509* certificate) +{ + Vector urls; + + auto* authority_info_access = static_cast(X509_get_ext_d2i(certificate, NID_info_access, nullptr, nullptr)); + if (!authority_info_access) + return urls; + + for (int i = 0; i < sk_ACCESS_DESCRIPTION_num(authority_info_access); ++i) { + auto* access_description = sk_ACCESS_DESCRIPTION_value(authority_info_access, i); + if (OBJ_obj2nid(access_description->method) != NID_ad_ca_issuers) + continue; + if (access_description->location->type != GEN_URI) + continue; + + auto const* uri = access_description->location->d.uniformResourceIdentifier; + auto url = ByteString { reinterpret_cast(ASN1_STRING_get0_data(uri)), static_cast(ASN1_STRING_length(uri)) }; + if (url.starts_with("http://"sv, CaseSensitivity::CaseInsensitive) && urls.size() < max_ca_issuers_urls_per_certificate) + urls.append(move(url)); + } + + AUTHORITY_INFO_ACCESS_free(authority_info_access); + return urls; +} + +// Process-wide cache of intermediate certs fetched via AIA. Each entry owns one reference to the X509. Bounded by +// max_cached_intermediates with FIFO eviction. +static Vector& intermediate_cache() +{ + static Vector cache; + return cache; +} + +// Process-wide record of caIssuers URLs whose fetch failed, mapped to when they failed — so we don't repeatedly retry a +// dead URL. Entries older than failed_url_retry_interval are ignored (and dropped when next encountered) — so a +// transiently-unreachable AIA server is eventually retried. +static HashMap& failed_urls() +{ + static HashMap urls; + return urls; +} + +void mark_aia_url_failed(ByteString url) +{ + if (failed_urls().size() >= max_failed_urls) + failed_urls().clear(); + failed_urls().set(move(url), MonotonicTime::now_coarse()); +} + +// Parse a fetched AIA response body into one or more certs. Real-world CAs serve a bare DER-encoded cert, a PKCS#7/ +// CMS "certs-only" bundle (RFC 5280 section 4.2.2.1), or PEM. +Vector parse_certificates(ReadonlyBytes body) +{ + Vector certificates; + + auto const* der = body.data(); + if (auto* certificate = d2i_X509(nullptr, &der, static_cast(body.size()))) { + certificates.append(certificate); + return certificates; + } + + auto const* pkcs7_data = body.data(); + if (auto* pkcs7 = d2i_PKCS7(nullptr, &pkcs7_data, static_cast(body.size()))) { + STACK_OF(X509)* certs = nullptr; + if (PKCS7_type_is_signed(pkcs7)) { + if (pkcs7->d.sign) + certs = pkcs7->d.sign->cert; + } else if (PKCS7_type_is_signedAndEnveloped(pkcs7)) { + if (pkcs7->d.signed_and_enveloped) + certs = pkcs7->d.signed_and_enveloped->cert; + } + for (int i = 0; certs && i < sk_X509_num(certs); ++i) { + auto* certificate = sk_X509_value(certs, i); + X509_up_ref(certificate); + certificates.append(certificate); + } + PKCS7_free(pkcs7); + if (!certificates.is_empty()) + return certificates; + } + + if (auto* bio = BIO_new_mem_buf(body.data(), static_cast(body.size()))) { + while (auto* certificate = PEM_read_bio_X509(bio, nullptr, nullptr, nullptr)) + certificates.append(certificate); + BIO_free(bio); + } + + return certificates; +} + +bool add_fetched_aia_intermediate(ReadonlyBytes body) +{ + auto certificates = parse_certificates(body); + for (auto* certificate : certificates) { + if (intermediate_cache().size() >= max_cached_intermediates) + X509_free(intermediate_cache().take_first()); + intermediate_cache().append(certificate); + } + return !certificates.is_empty(); +} + +// Full certificate-verification callback, installed via SSL_CTX_set_cert_verify_callback. We offer the AIA-fetched +// intermediates to path building as UNTRUSTED certificates — never as trust anchors — so the completed chain must still +// terminate at a locally-trusted root. On a verification failure, we record the failing cert's caIssuers URLs (skipping +// any recently known to be dead) — so the request layer can fetch them and retry. +static int verify_callback(X509_STORE_CTX* context, void* collector_data) +{ + auto* collector = static_cast(collector_data); + + // Add the fetched intermediates alongside the certs the server sent, as untrusted path-building material. + auto* server_untrusted = X509_STORE_CTX_get0_untrusted(context); + auto* untrusted = server_untrusted ? sk_X509_dup(server_untrusted) : sk_X509_new_null(); + if (untrusted) { + for (auto* candidate : intermediate_cache()) + sk_X509_push(untrusted, candidate); + X509_STORE_CTX_set0_untrusted(context, untrusted); + } + + auto result = X509_verify_cert(context); + + if (untrusted) { + X509_STORE_CTX_set0_untrusted(context, server_untrusted); + sk_X509_free(untrusted); + } + + // AIA can only repair a chain missing an intermediate issuer. For any other verification failure (expired, + // revoked, hostname mismatch, ...) fetching caIssuers is useless and would burn several 10s retries before the + // real error surfaces, so we leave the collector's pending URLs untouched. + auto const verify_error = X509_STORE_CTX_get_error(context); + auto const issuer_is_missing = verify_error == X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT || verify_error == X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT_LOCALLY; + + if (result <= 0 && collector && issuer_is_missing) { + if (auto* current = X509_STORE_CTX_get_current_cert(context)) { + for (auto& url : ca_issuers_urls(current)) { + if (collector->attempted_urls.contains(url)) + continue; + if (auto failed_at = failed_urls().get(url); failed_at.has_value()) { + if (MonotonicTime::now_coarse() - *failed_at < failed_url_retry_interval) + continue; + failed_urls().remove(url); + } + if (!collector->pending_urls.contains_slow(url)) + collector->pending_urls.append(move(url)); + } + } + } + + return result; +} + +// The SSL_CTX (per-connection, possibly pooled beyond the originating request) holds a strong reference to the +// collector; this releases it when the SSL_CTX is destroyed. +static void collector_ex_data_free(void*, void* ptr, CRYPTO_EX_DATA*, int, long, void*) +{ + if (ptr) + static_cast(ptr)->unref(); +} + +static int collector_ex_data_index() +{ + static int index = SSL_CTX_get_ex_new_index(0, nullptr, nullptr, nullptr, collector_ex_data_free); + return index; +} + +static CURLcode ssl_context_callback(CURL*, void* ssl_context, void* collector_data) +{ + auto* context = static_cast(ssl_context); + auto* collector = static_cast(collector_data); + collector->ref(); + SSL_CTX_set_ex_data(context, collector_ex_data_index(), collector); + SSL_CTX_set_cert_verify_callback(context, verify_callback, collector); + return CURLE_OK; +} + +void install_aia_verification_hook(CURL* easy_handle, AIACollector& collector) +{ + curl_easy_setopt(easy_handle, CURLOPT_SSL_CTX_FUNCTION, ssl_context_callback); + curl_easy_setopt(easy_handle, CURLOPT_SSL_CTX_DATA, &collector); +} + +} diff --git a/Services/RequestServer/AIA.h b/Services/RequestServer/AIA.h new file mode 100644 index 0000000000000..dd52f02eafce5 --- /dev/null +++ b/Services/RequestServer/AIA.h @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026-present, the Ladybird developers. + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +typedef void CURL; + +namespace RequestServer { + +// Per-request state shared with (per-connection, possibly pooled and longer-lived) SSL_CTX during verification. Ref- +// counted, so a connection outliving its originating request can't leave the verify callback with a dangling pointer. +class AIACollector : public RefCounted { +public: + Vector pending_urls; // caIssuers URLs collected during verification, awaiting fetch. + HashTable attempted_urls; // URLs already fetched for this request, so they're not re-collected. +}; + +// During verification, any cert whose issuer we can't find locally or in the fetched-intermediate cache has its +// caIssuers URLs appended to collector.pending_urls (skipping ones already attempted or recently known dead). +void install_aia_verification_hook(CURL* easy_handle, AIACollector& collector); + +// Parse a fetched AIA response body and, if it yields a certificate, add it to the process-wide intermediate cache +// that's consulted during verification. Returns true if a certificate was added. +bool add_fetched_aia_intermediate(ReadonlyBytes body); + +// Record that fetching the given caIssuers URL failed — so it's not retried. +void mark_aia_url_failed(ByteString url); + +// The following two parsing primitives are exposed for unit testing. +Vector ca_issuers_urls(X509* certificate); +Vector parse_certificates(ReadonlyBytes body); + +} diff --git a/Services/RequestServer/CMakeLists.txt b/Services/RequestServer/CMakeLists.txt index 723b507bcc564..44b05cd3d4c23 100644 --- a/Services/RequestServer/CMakeLists.txt +++ b/Services/RequestServer/CMakeLists.txt @@ -1,4 +1,5 @@ set(SOURCES + AIA.cpp ConnectionFromClient.cpp CURL.cpp Request.cpp diff --git a/Services/RequestServer/ConnectionFromClient.cpp b/Services/RequestServer/ConnectionFromClient.cpp index 4eec73ec9debf..3643329f69018 100644 --- a/Services/RequestServer/ConnectionFromClient.cpp +++ b/Services/RequestServer/ConnectionFromClient.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -126,6 +127,12 @@ ConnectionFromClient::~ConnectionFromClient() m_pending_websockets.clear(); m_websockets.clear(); + for (auto& fetch : m_aia_fetches) { + curl_multi_remove_handle(m_curl_multi, fetch.key); + curl_easy_cleanup(fetch.key); + } + m_aia_fetches.clear(); + curl_multi_cleanup(m_curl_multi); m_curl_multi = nullptr; } @@ -435,6 +442,11 @@ void ConnectionFromClient::check_active_requests() continue; } + if (reinterpret_cast(application_private) & aia_fetch_private_tag) { + complete_aia_fetch(msg->easy_handle, msg->data.result); + continue; + } + ++completions_drained; auto* request = static_cast(application_private); request->notify_fetch_complete({}, msg->data.result); @@ -444,6 +456,85 @@ void ConnectionFromClient::check_active_requests() dbgln_if(REQUESTSERVER_WIRE_DEBUG, "RequestServer wire-batch: drained {} completions in one curl multi tick", completions_drained); } +static size_t aia_write_body(char* data, size_t size, size_t count, void* user_data) +{ + auto& fetch = *static_cast(user_data); + auto const length = size * count; + if (fetch.body.size() + length > 64u * 1024) + return 0; + if (fetch.body.try_append(data, length).is_error()) + return 0; + return length; +} + +void ConnectionFromClient::fetch_aia_intermediate(Badge, ByteString const& url, u64 for_request_id) +{ + for (auto& in_flight : m_aia_fetches) { + if (in_flight.value->url == url) { + in_flight.value->request_ids.append(for_request_id); + return; + } + } + + auto* easy_handle = curl_easy_init(); + if (!easy_handle) { + if (auto request = m_active_requests.get(for_request_id); request.has_value()) + (*request)->retry_after_aia({}); + return; + } + + auto fetch = make(); + fetch->url = url; + fetch->request_ids.append(for_request_id); + + auto set_option = [&](auto option, auto value) { + if (auto result = curl_easy_setopt(easy_handle, option, value); result != CURLE_OK) + dbgln("RequestServer: AIA fetch failed to set curl option: {}", curl_easy_strerror(result)); + }; + set_option(CURLOPT_PRIVATE, reinterpret_cast(reinterpret_cast(fetch.ptr()) | aia_fetch_private_tag)); + set_option(CURLOPT_URL, url.characters()); + set_option(CURLOPT_PROTOCOLS_STR, "http"); + set_option(CURLOPT_REDIR_PROTOCOLS_STR, "http"); + set_option(CURLOPT_FOLLOWLOCATION, 1L); + set_option(CURLOPT_MAXREDIRS, 5L); + set_option(CURLOPT_TIMEOUT, 10L); + set_option(CURLOPT_MAXFILESIZE_LARGE, static_cast(64L * 1024)); + set_option(CURLOPT_NOSIGNAL, 1L); + set_option(CURLOPT_WRITEFUNCTION, aia_write_body); + set_option(CURLOPT_WRITEDATA, fetch.ptr()); + + auto result = curl_multi_add_handle(m_curl_multi, easy_handle); + VERIFY(result == CURLM_OK); + + m_aia_fetches.set(easy_handle, move(fetch)); +} + +void ConnectionFromClient::complete_aia_fetch(void* easy_handle, int result_code) +{ + long response_code = 0; + curl_easy_getinfo(easy_handle, CURLINFO_RESPONSE_CODE, &response_code); + + auto fetch = m_aia_fetches.take(easy_handle); + curl_multi_remove_handle(m_curl_multi, easy_handle); + curl_easy_cleanup(easy_handle); + if (!fetch.has_value()) + return; + + bool added = false; + if (result_code == CURLE_OK && response_code == 200) + added = add_fetched_aia_intermediate((*fetch)->body.bytes()); + + if (!added) { + dbgln_if(REQUESTSERVER_DEBUG, "AIA: intermediate fetch from {} failed (curl={}, status={})", (*fetch)->url, result_code, response_code); + mark_aia_url_failed((*fetch)->url); + } + + for (auto request_id : (*fetch)->request_ids) { + if (auto request = m_active_requests.get(request_id); request.has_value()) + (*request)->retry_after_aia({}); + } +} + void ConnectionFromClient::fail_websocket(u64 websocket_id, Requests::WebSocket::Error error) { async_websocket_ready_state_changed(websocket_id, to_underlying(Requests::WebSocket::ReadyState::Closed)); diff --git a/Services/RequestServer/ConnectionFromClient.h b/Services/RequestServer/ConnectionFromClient.h index 123ee81a6d3eb..5db716830d9a0 100644 --- a/Services/RequestServer/ConnectionFromClient.h +++ b/Services/RequestServer/ConnectionFromClient.h @@ -7,9 +7,11 @@ #pragma once #include +#include #include #include #include +#include #include #include #include @@ -25,6 +27,12 @@ namespace RequestServer { +struct AIAFetch { + ByteString url; + ByteBuffer body; + Vector request_ids; +}; + class ConnectionFromClient final : public IPC::ConnectionFromClient { C_OBJECT(ConnectionFromClient); @@ -47,6 +55,7 @@ class ConnectionFromClient final void start_revalidation_request(Badge, ByteString method, URL::URL, NonnullRefPtr request_headers, ByteBuffer request_body, HTTP::Cookie::IncludeCredentials, Core::ProxyData proxy_data); void request_complete(Badge, Request const&); + void fetch_aia_intermediate(Badge, ByteString const& url, u64 for_request_id); private: ConnectionFromClient(NonnullOwnPtr, IsPrimaryConnection, IsPrivate, ConnectionMap&, Optional, ByteString alt_svc_cache_path); @@ -85,6 +94,7 @@ class ConnectionFromClient final static int on_socket_callback(void*, int sockfd, int what, void* user_data, void*); static int on_timeout_callback(void*, long timeout_ms, void* user_data); void check_active_requests(); + void complete_aia_fetch(void* easy_handle, int result_code); void fail_websocket(u64 websocket_id, Requests::WebSocket::Error); ErrorOr create_client_socket(IsPrivate); @@ -98,6 +108,7 @@ class ConnectionFromClient final HashMap> m_active_requests; HashMap> m_active_revalidation_requests; + HashMap> m_aia_fetches; HashTable m_pending_websockets; HashMap> m_websockets; @@ -115,5 +126,6 @@ class ConnectionFromClient final }; constexpr inline uintptr_t websocket_private_tag = 0x1; +constexpr inline uintptr_t aia_fetch_private_tag = 0x2; } diff --git a/Services/RequestServer/Request.cpp b/Services/RequestServer/Request.cpp index 952a3d0be83db..90403acb334f3 100644 --- a/Services/RequestServer/Request.cpp +++ b/Services/RequestServer/Request.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -524,6 +525,8 @@ void Request::notify_retrieved_http_cookie(Badge, StringVi transition_to_state(State::Fetch); } +static constexpr size_t max_aia_fetches_per_request = 5; + void Request::notify_fetch_complete(Badge, int result_code) { mark_lifecycle_event(this, &WireStats::complete_observed_at); @@ -533,6 +536,12 @@ void Request::notify_fetch_complete(Badge, int result_code log_chunk_stats(this); } + if (m_type == RequestType::Fetch && result_code == CURLE_PEER_FAILED_VERIFICATION && m_aia_collector && !m_aia_collector->pending_urls.is_empty() && m_aia_fetch_count < max_aia_fetches_per_request) { + m_curl_result_code = result_code; + transition_to_state(State::Complete); + return; + } + if (is_revalidation_request()) { if (acquire_status_code() == 304) { if (m_type == RequestType::BackgroundRevalidation && m_disk_cache->mode() == HTTP::DiskCache::Mode::Testing) @@ -555,6 +564,36 @@ void Request::notify_fetch_complete(Badge, int result_code transition_to_state(State::Complete); } +void Request::start_aia_fetch_and_retry() +{ + ++m_aia_fetch_count; + auto url = m_aia_collector->pending_urls.take_first(); + m_aia_collector->attempted_urls.set(url); + dbgln_if(REQUESTSERVER_DEBUG, "AIA: fetching missing intermediate from {} (attempt {})", url, m_aia_fetch_count); + m_client->fetch_aia_intermediate({}, url, m_request_id); + transition_to_state(State::WaitForAIA); +} + +void Request::retry_after_aia(Badge) +{ + if (m_state != State::WaitForAIA) + return; + + if (m_curl_easy_handle) { + if (m_curl_easy_handle_is_in_multi) { + auto result = curl_multi_remove_handle(m_curl_multi_handle, m_curl_easy_handle); + VERIFY(result == CURLM_OK); + } + curl_easy_cleanup(m_curl_easy_handle); + m_curl_easy_handle = nullptr; + m_curl_easy_handle_is_in_multi = false; + } + m_curl_result_code = {}; + if (m_aia_collector) + m_aia_collector->pending_urls.clear(); + transition_to_state(State::Fetch); +} + void Request::transition_to_state(State state) { dbgln_if(REQUESTSERVER_DEBUG, "Request::Transition[{}]: {} -> {} ({} {})", m_request_id, state_name(m_state), state_name(state), m_method, m_url); @@ -574,6 +613,9 @@ void Request::process() case State::WaitForCache: // Do nothing; we are waiting for the disk cache to notify us to proceed. break; + case State::WaitForAIA: + // Do nothing; we are waiting for the AIA intermediate-certificate fetch to notify us to proceed. + break; case State::FailedCacheOnly: handle_failed_cache_only_state(); break; @@ -912,7 +954,7 @@ void Request::handle_fetch_state() auto is_revalidation_request = this->is_revalidation_request(); - if (!is_revalidation_request) { + if (!is_revalidation_request && !m_informed_client_request_started) { if (inform_client_request_started().is_error()) return; } @@ -929,6 +971,12 @@ void Request::handle_fetch_state() if (auto const& path = default_certificate_path(); !path.is_empty()) set_option(CURLOPT_CAINFO, path.characters()); +#ifndef AK_OS_MACOS + if (!m_aia_collector) + m_aia_collector = make_ref_counted(); + install_aia_verification_hook(m_curl_easy_handle, *m_aia_collector); +#endif + set_option(CURLOPT_ACCEPT_ENCODING, ""); // Empty string lets curl define the accepted encodings. set_option(CURLOPT_URL, m_url.to_byte_string().characters()); set_option(CURLOPT_PORT, m_url.port_or_default()); @@ -1039,6 +1087,11 @@ void Request::handle_complete_state() if (m_curl_result_code == CURLE_RECV_ERROR && m_bytes_transferred_to_client != 0 && !m_response_headers->contains("Content-Length"sv)) m_curl_result_code = CURLE_OK; + if (m_curl_result_code == CURLE_PEER_FAILED_VERIFICATION && m_aia_collector && !m_aia_collector->pending_urls.is_empty() && m_aia_fetch_count < max_aia_fetches_per_request) { + start_aia_fetch_and_retry(); + return; + } + if (m_curl_result_code != CURLE_OK) { m_network_error = curl_code_to_network_error(*m_curl_result_code); @@ -1246,6 +1299,7 @@ ErrorOr Request::inform_client_request_started() m_client_request_pipe = request_pipe.release_value(); TRY(send_request_pipe_to_client()); + m_informed_client_request_started = true; return {}; } diff --git a/Services/RequestServer/Request.h b/Services/RequestServer/Request.h index 8ecad328a70ce..180b6271379e6 100644 --- a/Services/RequestServer/Request.h +++ b/Services/RequestServer/Request.h @@ -31,6 +31,8 @@ struct curl_slist; namespace RequestServer { +class AIACollector; + class Request final : public HTTP::CacheRequest { public: static NonnullOwnPtr fetch( @@ -85,6 +87,7 @@ class Request final : public HTTP::CacheRequest { virtual void notify_request_unblocked(Badge) override; void notify_retrieved_http_cookie(Badge, StringView cookie); void notify_fetch_complete(Badge, int result_code); + void retry_after_aia(Badge); private: struct TransferredBodyFile { @@ -109,6 +112,7 @@ class Request final : public HTTP::CacheRequest { RetrieveCookie, // Retrieve cookies from the UI process. Connect, // Issue a network request to connect to the URL. Fetch, // Issue a network request to fetch the URL. + WaitForAIA, // Wait for an AIA intermediate-certificate fetch to complete, then retry the fetch. Complete, // Finalize the request with the client. Error, // Any error occured during the request's lifetime. }; @@ -122,6 +126,8 @@ class Request final : public HTTP::CacheRequest { return "ReadCache"sv; case State::WaitForCache: return "WaitForCache"sv; + case State::WaitForAIA: + return "WaitForAIA"sv; case State::FailedCacheOnly: return "FailedCacheOnly"sv; case State::ServeSubstitution: @@ -185,6 +191,7 @@ class Request final : public HTTP::CacheRequest { ErrorOr detach_curl_handle_from_multi(); ErrorOr inform_client_request_started(); + void start_aia_fetch_and_retry(); ErrorOr send_request_pipe_to_client(); ErrorOr send_transferred_body_file_to_client(); void transfer_headers_to_client_if_needed(); @@ -211,6 +218,10 @@ class Request final : public HTTP::CacheRequest { void* m_curl_easy_handle { nullptr }; bool m_curl_easy_handle_is_in_multi { false }; Vector m_curl_string_lists; + + RefPtr m_aia_collector; + size_t m_aia_fetch_count { 0 }; + bool m_informed_client_request_started { false }; Optional m_curl_result_code; NonnullRefPtr m_resolver; diff --git a/Tests/CMakeLists.txt b/Tests/CMakeLists.txt index 1dea8552b5578..387812511c4d1 100644 --- a/Tests/CMakeLists.txt +++ b/Tests/CMakeLists.txt @@ -18,6 +18,7 @@ add_subdirectory(LibUnicode) add_subdirectory(LibURL) add_subdirectory(LibWasm) add_subdirectory(LibXML) +add_subdirectory(RequestServer) add_subdirectory(Meta) if (ENABLE_GUI_TARGETS) diff --git a/Tests/LibWeb/Fixtures/http-test-server.py b/Tests/LibWeb/Fixtures/http-test-server.py index fd16fff4b8b64..d2bce6869d820 100755 --- a/Tests/LibWeb/Fixtures/http-test-server.py +++ b/Tests/LibWeb/Fixtures/http-test-server.py @@ -10,6 +10,7 @@ import os import socket import socketserver +import ssl import sys import threading import time @@ -155,6 +156,8 @@ class TestHTTPServer(socketserver.ThreadingTCPServer): class TestHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): static_directory: str wpt_directory: str + aia_artifacts: dict + aia_config: Optional[dict] def __init__(self, *arguments, **kwargs): super().__init__(*arguments, directory=self.static_directory, **kwargs) @@ -252,6 +255,32 @@ def _serve_wpt_file_request(self): def _serve_static_request(self): self._serve_wpt_file_request() + def _serve_aia_artifact(self, request_path): + # Serves the intermediate certificate that the broken-chain HTTPS hosts point at via their AIA caIssuers URL. + # Fetched by RequestServer, not by the page. + entry = getattr(TestHTTPRequestHandler, "aia_artifacts", {}).get(request_path) + if entry is None: + # for example, /aia/dead: a deliberately missing artifact, to exercise the failure path. + self.send_error(404, "Not Found") + return + content_type, body = entry + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _serve_aia_config(self): + # Lets the test page discover the per-scenario HTTPS ports (each an OS-assigned port). + config = getattr(TestHTTPRequestHandler, "aia_config", None) + body = json.dumps(config or {}).encode("utf-8") + self.send_response(200 if config else 503) + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + def do_GET(self): if self.headers.get("Upgrade", "").lower() == "websocket": self._serve_websocket_echo() @@ -267,6 +296,10 @@ def do_GET(self): self._serve_recorded_request_headers() elif request_path.endswith(".py"): self._serve_wpt_python_script() + elif request_path.startswith("/aia/"): + self._serve_aia_artifact(request_path) + elif request_path == "/aia-test/config": + self._serve_aia_config() else: self._serve_static_request() @@ -636,7 +669,211 @@ def _send_websocket_close(self): pass -def start_server(port, static_directory): +class AIALeafHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", "2") + self.end_headers() + self.wfile.write(b"ok") + + def log_message(self, format: str, *args: Any) -> None: + pass + + +class AIATLSServer(socketserver.ThreadingTCPServer): + daemon_threads = True + allow_reuse_address = True + + def __init__(self, server_address, handler, ssl_context): + super().__init__(server_address, handler) + self._ssl_context = ssl_context + + def get_request(self): + sock, addr = super().get_request() + try: + return self._ssl_context.wrap_socket(sock, server_side=True), addr + except OSError: + sock.close() + raise + + +def _setup_aia_test_servers(http_port, ca_cert_output): + try: + from cryptography import x509 # type: ignore[import-not-found] + from cryptography.hazmat.primitives import hashes # type: ignore[import-not-found] + from cryptography.hazmat.primitives.asymmetric import ec # type: ignore[import-not-found] + from cryptography.hazmat.primitives.serialization import Encoding # type: ignore[import-not-found] + from cryptography.hazmat.primitives.serialization import NoEncryption # type: ignore[import-not-found] + from cryptography.hazmat.primitives.serialization import PrivateFormat # type: ignore[import-not-found] + from cryptography.hazmat.primitives.serialization import pkcs7 # type: ignore[import-not-found] + from cryptography.x509.oid import AuthorityInformationAccessOID # type: ignore[import-not-found] + from cryptography.x509.oid import ExtendedKeyUsageOID # type: ignore[import-not-found] + from cryptography.x509.oid import NameOID # type: ignore[import-not-found] + except ImportError as error: + logging.warning("AIA test setup skipped: python 'cryptography' unavailable (%s)", error) + return + + import datetime + import tempfile + + def new_key(): + return ec.generate_private_key(ec.SECP256R1()) + + def dn(common_name): + return x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)]) + + not_before, not_after = datetime.datetime(2020, 1, 1), datetime.datetime(2050, 1, 1) + + root_key = new_key() + root = ( + x509.CertificateBuilder() + .subject_name(dn("Ladybird AIA Test Root")) + .issuer_name(dn("Ladybird AIA Test Root")) + .public_key(root_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(not_before) + .not_valid_after(not_after) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .sign(root_key, hashes.SHA256()) + ) + + aia_base = f"http://127.0.0.1:{http_port}/aia" + artifacts = {} + scenarios = {} + + def add_scenario(name, host, artifact_name, encoding, shared=None): + # By default, each scenario gets its own intermediate — so fetching one never populates the cache for another + # (which would mask whether that scenario's own fetch and parse ran). The de-dupe scenarios instead share one + # intermediate — to check it's fetched only once. + if shared is not None: + intermediate, intermediate_key = shared + else: + intermediate_key = new_key() + intermediate = ( + x509.CertificateBuilder() + .subject_name(dn(f"Ladybird AIA Test Intermediate ({name})")) + .issuer_name(root.subject) + .public_key(intermediate_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(not_before) + .not_valid_after(not_after) + .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) + .sign(root_key, hashes.SHA256()) + ) + if encoding == "der": + artifacts[f"/aia/{artifact_name}"] = ("application/pkix-cert", intermediate.public_bytes(Encoding.DER)) + aia_url = f"{aia_base}/{artifact_name}" + elif encoding == "pkcs7": + artifacts[f"/aia/{artifact_name}"] = ( + "application/pkcs7-mime", + pkcs7.serialize_certificates([intermediate], Encoding.DER), + ) + aia_url = f"{aia_base}/{artifact_name}" + else: + aia_url = f"{aia_base}/dead" # 404: the intermediate is never published + + leaf_key = new_key() + leaf = ( + x509.CertificateBuilder() + .subject_name(dn(host)) + .issuer_name(intermediate.subject) + .public_key(leaf_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(not_before) + .not_valid_after(not_after) + .add_extension(x509.SubjectAlternativeName([x509.DNSName("localhost")]), critical=False) + .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False) + .add_extension( + x509.AuthorityInformationAccess( + [ + x509.AccessDescription( + AuthorityInformationAccessOID.CA_ISSUERS, x509.UniformResourceIdentifier(aia_url) + ) + ] + ), + critical=False, + ) + .sign(intermediate_key, hashes.SHA256()) + ) + scenarios[name] = (leaf, leaf_key) + return intermediate, intermediate_key + + add_scenario("good_der", "good-der.localhost", "intermediate-der.der", "der") + add_scenario("good_pkcs7", "good-pkcs7.localhost", "intermediate-pkcs7.p7c", "pkcs7") + add_scenario("dead", "dead.localhost", None, "dead") + shared_intermediate = add_scenario("dedup_a", "dedup-a.localhost", "intermediate-dedup.der", "der") + add_scenario("dedup_b", "dedup-b.localhost", "intermediate-dedup.der", "der", shared_intermediate) + + # Negative trust control: The caIssuers URL serves a self-signed cert that's *not* anchored in the trusted test + # root. A fetched intermediate must never complete a chain to an untrusted anchor — so this host must fail to load. + untrusted_key = new_key() + untrusted = ( + x509.CertificateBuilder() + .subject_name(dn("Ladybird AIA Test UNTRUSTED root")) + .issuer_name(dn("Ladybird AIA Test UNTRUSTED root")) + .public_key(untrusted_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(not_before) + .not_valid_after(not_after) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .sign(untrusted_key, hashes.SHA256()) + ) + artifacts["/aia/untrusted.der"] = ("application/pkix-cert", untrusted.public_bytes(Encoding.DER)) + untrusted_leaf_key = new_key() + untrusted_leaf = ( + x509.CertificateBuilder() + .subject_name(dn("untrusted-leaf.localhost")) + .issuer_name(untrusted.subject) + .public_key(untrusted_leaf_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(not_before) + .not_valid_after(not_after) + .add_extension(x509.SubjectAlternativeName([x509.DNSName("localhost")]), critical=False) + .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False) + .add_extension( + x509.AuthorityInformationAccess( + [ + x509.AccessDescription( + AuthorityInformationAccessOID.CA_ISSUERS, + x509.UniformResourceIdentifier(f"{aia_base}/untrusted.der"), + ) + ] + ), + critical=False, + ) + .sign(untrusted_key, hashes.SHA256()) + ) + scenarios["untrusted"] = (untrusted_leaf, untrusted_leaf_key) + + TestHTTPRequestHandler.aia_artifacts = artifacts + + # Write the root CA so RequestServer (via --certificate/CAINFO) trusts the completed chains. + with open(ca_cert_output, "wb") as ca_file: + ca_file.write(root.public_bytes(Encoding.PEM)) + + scratch = tempfile.mkdtemp(prefix="ladybird-aia-") + config = {} + for name, (leaf, leaf_key) in scenarios.items(): + # The cert file holds *only* the leaf — so, each HTTPS host presents a broken chain (the intermediate is omitted + # and must be fetched via AIA). + cert_path = os.path.join(scratch, f"{name}.crt") + key_path = os.path.join(scratch, f"{name}.key") + with open(cert_path, "wb") as f: + f.write(leaf.public_bytes(Encoding.PEM)) + with open(key_path, "wb") as f: + f.write(leaf_key.private_bytes(Encoding.PEM, PrivateFormat.TraditionalOpenSSL, NoEncryption())) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(certfile=cert_path, keyfile=key_path) + server = AIATLSServer(("127.0.0.1", 0), AIALeafHandler, context) + config[name] = server.socket.getsockname()[1] + threading.Thread(target=server.serve_forever, daemon=True).start() + + TestHTTPRequestHandler.aia_config = config + logging.info("AIA test servers ready: %s (root CA at %s)", config, ca_cert_output) + + +def start_server(port, static_directory, ca_cert_output=None): TestHTTPRequestHandler.static_directory = os.path.abspath(static_directory) TestHTTPRequestHandler.wpt_directory = os.path.join( TestHTTPRequestHandler.static_directory, "Text", "input", "wpt-import" @@ -653,6 +890,12 @@ def start_server(port, static_directory): url_base="/static/", ) + if ca_cert_output: + try: + _setup_aia_test_servers(httpd.socket.getsockname()[1], ca_cert_output) + except Exception as error: + logging.exception("AIA test setup failed: %s", error) + print(httpd.socket.getsockname()[1]) sys.stdout.flush() @@ -681,6 +924,12 @@ def start_server(port, static_directory): default=0, help="Port to run the server on", ) + parser.add_argument( + "--ca-cert-output", + type=str, + default=None, + help="Path to write the AIA test root CA (PEM) to; enables the AIA broken-chain HTTPS servers", + ) args = parser.parse_args() - start_server(port=args.port, static_directory=args.directory) + start_server(port=args.port, static_directory=args.directory, ca_cert_output=args.ca_cert_output) diff --git a/Tests/LibWeb/Text/expected/aia-cert-fetching.txt b/Tests/LibWeb/Text/expected/aia-cert-fetching.txt new file mode 100644 index 0000000000000..324084a2534d8 --- /dev/null +++ b/Tests/LibWeb/Text/expected/aia-cert-fetching.txt @@ -0,0 +1,5 @@ +DER intermediate via AIA: loaded +PKCS#7 intermediate via AIA: loaded +dead AIA URL: failed +untrusted intermediate (must not validate): failed +both hosts sharing an intermediate: loaded, loaded diff --git a/Tests/LibWeb/Text/input/aia-cert-fetching.html b/Tests/LibWeb/Text/input/aia-cert-fetching.html new file mode 100644 index 0000000000000..119a3b29dcd27 --- /dev/null +++ b/Tests/LibWeb/Text/input/aia-cert-fetching.html @@ -0,0 +1,48 @@ + + + diff --git a/Tests/LibWeb/test-web/Application.cpp b/Tests/LibWeb/test-web/Application.cpp index fdf456150ac63..9268fe5cc94b7 100644 --- a/Tests/LibWeb/test-web/Application.cpp +++ b/Tests/LibWeb/test-web/Application.cpp @@ -7,8 +7,10 @@ #include "Application.h" #include "Fixture.h" +#include #include #include +#include #include namespace TestWeb { @@ -73,6 +75,10 @@ void Application::create_platform_options(WebView::BrowserOptions& browser_optio request_server_options.http_disk_cache_mode = WebView::HTTPDiskCacheMode::Testing; + // Trust the AIA integration test's root CA (written by the http-test-server fixture) — so its broken-chain HTTPS + // hosts validate once their intermediate is fetched. Must match the path passed in HttpEchoServerFixture::setup. + request_server_options.certificates.append(LexicalPath::join(Core::StandardPaths::tempfile_directory(), "ladybird-aia-test-ca.pem"sv).string()); + web_content_options.is_test_mode = WebView::IsTestMode::Yes; // Allow window.open() to succeed for tests. diff --git a/Tests/LibWeb/test-web/Fixture.cpp b/Tests/LibWeb/test-web/Fixture.cpp index b39dc188aede2..a0e0a1cb41923 100644 --- a/Tests/LibWeb/test-web/Fixture.cpp +++ b/Tests/LibWeb/test-web/Fixture.cpp @@ -64,7 +64,10 @@ void HttpEchoServerFixture::teardown_impl() ErrorOr HttpEchoServerFixture::setup(WebView::WebContentOptions& web_content_options) { auto const script_path = LexicalPath::join(s_fixtures_path, m_script_path); - auto const arguments = Vector { script_path.string(), "--directory", Application::the().test_root_path }; + // Path where http-test-server.py writes the AIA test root CA. Must match the certificate trusted by + // TestWeb::Application::create_platform_options. + auto const ca_cert_path = LexicalPath::join(Core::StandardPaths::tempfile_directory(), "ladybird-aia-test-ca.pem"sv).string(); + auto const arguments = Vector { script_path.string(), "--directory", Application::the().test_root_path, "--ca-cert-output", ca_cert_path }; // FIXME: Pick a more reasonable log path that is more observable auto const log_path = LexicalPath::join(Core::StandardPaths::tempfile_directory(), "http-test-server.log"sv).string(); diff --git a/Tests/LibWeb/test-web/main.cpp b/Tests/LibWeb/test-web/main.cpp index 155b200b75ebc..266b5a2b46418 100644 --- a/Tests/LibWeb/test-web/main.cpp +++ b/Tests/LibWeb/test-web/main.cpp @@ -177,6 +177,18 @@ static ErrorOr skip_ui_process_session_history_tests_unless_enabled(Applic return {}; } +static ErrorOr skip_aia_tests_on_apple(Application const& app) +{ +#ifdef AK_OS_MACOS + auto path = LexicalPath::join(app.test_root_path, "Text/input/aia-cert-fetching.html"sv).string(); + if (FileSystem::exists(path)) + s_skipped_tests.append(TRY(real_path_for_test_input(path))); +#else + (void)app; +#endif + return {}; +} + static void log_active_test_views(StringView reason) { outln(); @@ -1025,6 +1037,7 @@ static ErrorOr run_tests(Core::AnonymousBuffer const& theme, Web::DevicePix TRY(load_test_config(app.test_root_path)); TRY(skip_async_scrolling_tests_unless_enabled(app)); TRY(skip_ui_process_session_history_tests_unless_enabled(app)); + TRY(skip_aia_tests_on_apple(app)); Vector tests; diff --git a/Tests/RequestServer/CMakeLists.txt b/Tests/RequestServer/CMakeLists.txt new file mode 100644 index 0000000000000..c70dd326779b6 --- /dev/null +++ b/Tests/RequestServer/CMakeLists.txt @@ -0,0 +1,3 @@ +ladybird_test(TestAIA.cpp RequestServer LIBS OpenSSL::Crypto OpenSSL::SSL CURL::libcurl) +target_sources(TestAIA PRIVATE ${LADYBIRD_SOURCE_DIR}/Services/RequestServer/AIA.cpp) +target_include_directories(TestAIA PRIVATE ${LADYBIRD_SOURCE_DIR}/Services) diff --git a/Tests/RequestServer/TestAIA.cpp b/Tests/RequestServer/TestAIA.cpp new file mode 100644 index 0000000000000..c0e1f378c6a2f --- /dev/null +++ b/Tests/RequestServer/TestAIA.cpp @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026-present, the Ladybird developers. + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include + +#include + +// Generated certificate fixtures (EC/prime256v1, fixed validity 2020-2050): +// - LEAF: subject CN "leaf.aia.test"; AIA extension with one http caIssuers URL, one https caIssuers URL, one http OCSP URL. +// - CA: self-signed "AIA Test Root CA"; no AIA extension. +// - MANY: subject CN "many.aia.test"; AIA extension carrying six http caIssuers URLs. +// - PKCS7_TWO_CERTS: a PKCS#7 "certs-only" bundle containing [CA, leaf]. +static constexpr auto leaf_der_base64 = "MIIBnTCCAUSgAwIBAgIBAjAKBggqhkjOPQQDAjAbMRkwFwYDVQQDDBBBSUEgVGVzdCBSb290IENBMCAXDTIwMDEwMTAwMDAwMFoYDzIwNTAwMTAxMDAwMDAwWjAYMRYwFAYDVQQDDA1sZWFmLmFpYS50ZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEYsSP5b9NEG+zYqFyllVmL2kHe/eNXc0XUsRDFdTEBEFxn7I2cT7f8h8yjcrf63UgeEToVHWW5ySz1tzdbzCk9aN6MHgwdgYIKwYBBQUHAQEEajBoMCIGCCsGAQUFBzAChhZodHRwOi8vYWlhLnRlc3QvY2EuY3J0MCMGCCsGAQUFBzAChhdodHRwczovL2FpYS50ZXN0L2NhLmNydDAdBggrBgEFBQcwAYYRaHR0cDovL29jc3AudGVzdC8wCgYIKoZIzj0EAwIDRwAwRAIgR3a5MGrx7BBesYV8arbwfeBIprTELDzniCerSZuyHjgCIHn480IuXwqGJBJcBwqwvFcyRLEnfVI1HqmkCDElMA3C"sv; +static constexpr auto ca_der_base64 = "MIIBODCB4KADAgECAgEBMAoGCCqGSM49BAMCMBsxGTAXBgNVBAMMEEFJQSBUZXN0IFJvb3QgQ0EwIBcNMjAwMTAxMDAwMDAwWhgPMjA1MDAxMDEwMDAwMDBaMBsxGTAXBgNVBAMMEEFJQSBUZXN0IFJvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQvFaFCRjw2v74zuhfRNy6CQSj16eQo3DvL5UjoJo6Ic+jlA/En7yPQWBTdksQFW/3mxWqeoSShdsEzEVEkC84toxMwETAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIFcoD2A6nwl2nz8sgjQUl1h/vEmP2ou36HfWDjHNrUDBAiBuf54FrlwV+CbAPcABgP5ktbYmt7Ho0zaKWSuDSI/EeA=="sv; +static constexpr auto many_der_base64 = "MIICGTCCAb+gAwIBAgIBAjAKBggqhkjOPQQDAjAbMRkwFwYDVQQDDBBBSUEgVGVzdCBSb290IENBMCAXDTIwMDEwMTAwMDAwMFoYDzIwNTAwMTAxMDAwMDAwWjAYMRYwFAYDVQQDDA1tYW55LmFpYS50ZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExI4Mkk2uxbNU9fEXyoU9VSo172CiHbWDDbRkOhesAmAJHuBTleAkmTLAEWugT6bILtQTKCq5GldbYwMrHMJ2PaOB9DCB8TCB7gYIKwYBBQUHAQEEgeEwgd4wIwYIKwYBBQUHMAKGF2h0dHA6Ly9haWEudGVzdC9jYTAuY3J0MCMGCCsGAQUFBzAChhdodHRwOi8vYWlhLnRlc3QvY2ExLmNydDAjBggrBgEFBQcwAoYXaHR0cDovL2FpYS50ZXN0L2NhMi5jcnQwIwYIKwYBBQUHMAKGF2h0dHA6Ly9haWEudGVzdC9jYTMuY3J0MCMGCCsGAQUFBzAChhdodHRwOi8vYWlhLnRlc3QvY2E0LmNydDAjBggrBgEFBQcwAoYXaHR0cDovL2FpYS50ZXN0L2NhNS5jcnQwCgYIKoZIzj0EAwIDSAAwRQIgGwvZA7qgH9wcYmBZGRbWDbdsKQ7vbzyt27lQkobpVOgCIQCZZz5VBu6Ycv7e7i44STvL7tpiu2CeTqq5WQk6ZSvH+g=="sv; +static constexpr auto pkcs7_two_certs_base64 = "MIIDCAYJKoZIhvcNAQcCoIIC+TCCAvUCAQExADALBgkqhkiG9w0BBwGgggLdMIIBODCB4KADAgECAgEBMAoGCCqGSM49BAMCMBsxGTAXBgNVBAMMEEFJQSBUZXN0IFJvb3QgQ0EwIBcNMjAwMTAxMDAwMDAwWhgPMjA1MDAxMDEwMDAwMDBaMBsxGTAXBgNVBAMMEEFJQSBUZXN0IFJvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQvFaFCRjw2v74zuhfRNy6CQSj16eQo3DvL5UjoJo6Ic+jlA/En7yPQWBTdksQFW/3mxWqeoSShdsEzEVEkC84toxMwETAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIFcoD2A6nwl2nz8sgjQUl1h/vEmP2ou36HfWDjHNrUDBAiBuf54FrlwV+CbAPcABgP5ktbYmt7Ho0zaKWSuDSI/EeDCCAZ0wggFEoAMCAQICAQIwCgYIKoZIzj0EAwIwGzEZMBcGA1UEAwwQQUlBIFRlc3QgUm9vdCBDQTAgFw0yMDAxMDEwMDAwMDBaGA8yMDUwMDEwMTAwMDAwMFowGDEWMBQGA1UEAwwNbGVhZi5haWEudGVzdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABGLEj+W/TRBvs2KhcpZVZi9pB3v3jV3NF1LEQxXUxARBcZ+yNnE+3/IfMo3K3+t1IHhE6FR1lucks9bc3W8wpPWjejB4MHYGCCsGAQUFBwEBBGowaDAiBggrBgEFBQcwAoYWaHR0cDovL2FpYS50ZXN0L2NhLmNydDAjBggrBgEFBQcwAoYXaHR0cHM6Ly9haWEudGVzdC9jYS5jcnQwHQYIKwYBBQUHMAGGEWh0dHA6Ly9vY3NwLnRlc3QvMAoGCCqGSM49BAMCA0cAMEQCIEd2uTBq8ewQXrGFfGq28H3gSKa0xCw854gnq0mbsh44AiB5+PNCLl8KhiQSXAcKsLxXMkSxJ31SNR6ppAgxJTANwjEA"sv; +static constexpr auto ca_pem = R"PEM(-----BEGIN CERTIFICATE----- +MIIBODCB4KADAgECAgEBMAoGCCqGSM49BAMCMBsxGTAXBgNVBAMMEEFJQSBUZXN0 +IFJvb3QgQ0EwIBcNMjAwMTAxMDAwMDAwWhgPMjA1MDAxMDEwMDAwMDBaMBsxGTAX +BgNVBAMMEEFJQSBUZXN0IFJvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNC +AAQvFaFCRjw2v74zuhfRNy6CQSj16eQo3DvL5UjoJo6Ic+jlA/En7yPQWBTdksQF +W/3mxWqeoSShdsEzEVEkC84toxMwETAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49 +BAMCA0cAMEQCIFcoD2A6nwl2nz8sgjQUl1h/vEmP2ou36HfWDjHNrUDBAiBuf54F +rlwV+CbAPcABgP5ktbYmt7Ho0zaKWSuDSI/EeA== +-----END CERTIFICATE-----)PEM"sv; + +static X509* parse_der(StringView base64) +{ + auto der = MUST(decode_base64(base64)); + auto const* data = der.data(); + return d2i_X509(nullptr, &data, static_cast(der.size())); +} + +static ByteString subject_common_name(X509* certificate) +{ + char buffer[256] = {}; + X509_NAME_get_text_by_NID(X509_get_subject_name(certificate), NID_commonName, buffer, sizeof(buffer)); + return ByteString { buffer }; +} + +TEST_CASE(ca_issuers_urls_returns_only_http_ca_issuers) +{ + auto* leaf = parse_der(leaf_der_base64); + EXPECT_NE(leaf, nullptr); + + // Of the three access descriptions, only the http caIssuers URL is returned: The https caIssuers URL is skipped + // (fetching over TLS would itself need verification), and the OCSP URL isn't a caIssuers entry. + auto urls = RequestServer::ca_issuers_urls(leaf); + EXPECT_EQ(urls.size(), 1u); + EXPECT_EQ(urls[0], "http://aia.test/ca.crt"sv); + + X509_free(leaf); +} + +TEST_CASE(ca_issuers_urls_is_capped) +{ + auto* many = parse_der(many_der_base64); + EXPECT_NE(many, nullptr); + + // The certificate carries six http caIssuers URLs; extraction is capped at five. + EXPECT_EQ(RequestServer::ca_issuers_urls(many).size(), 5u); + + X509_free(many); +} + +TEST_CASE(ca_issuers_urls_empty_without_extension) +{ + auto* ca = parse_der(ca_der_base64); + EXPECT_NE(ca, nullptr); + + EXPECT(RequestServer::ca_issuers_urls(ca).is_empty()); + + X509_free(ca); +} + +TEST_CASE(parse_certificates_reads_der) +{ + auto der = MUST(decode_base64(leaf_der_base64)); + auto certificates = RequestServer::parse_certificates(der.bytes()); + EXPECT_EQ(certificates.size(), 1u); + EXPECT_EQ(subject_common_name(certificates[0]), "leaf.aia.test"sv); + for (auto* certificate : certificates) + X509_free(certificate); +} + +TEST_CASE(parse_certificates_reads_pkcs7_certs_only_bundle) +{ + // A PKCS#7 "certs-only" response can carry more than one certificate; all are returned. + auto der = MUST(decode_base64(pkcs7_two_certs_base64)); + auto certificates = RequestServer::parse_certificates(der.bytes()); + EXPECT_EQ(certificates.size(), 2u); + for (auto* certificate : certificates) + X509_free(certificate); +} + +TEST_CASE(parse_certificates_reads_pem) +{ + auto certificates = RequestServer::parse_certificates(ca_pem.bytes()); + EXPECT_EQ(certificates.size(), 1u); + EXPECT_EQ(subject_common_name(certificates[0]), "AIA Test Root CA"sv); + for (auto* certificate : certificates) + X509_free(certificate); +} + +TEST_CASE(parse_certificates_rejects_garbage) +{ + EXPECT(RequestServer::parse_certificates("this is not a certificate"sv.bytes()).is_empty()); +} + +TEST_CASE(parse_certificates_handles_degenerate_pkcs7_without_crashing) +{ + // A PKCS#7 SignedData whose (OPTIONAL) content is absent decodes with a NULL content pointer; parsing must not + // dereference it. Body: SEQUENCE { OBJECT IDENTIFIER signedData } with no content. + static constexpr Array degenerate_pkcs7 { 0x30, 0x0B, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x07, 0x02 }; + EXPECT(RequestServer::parse_certificates(degenerate_pkcs7).is_empty()); +}