test(https): cover the TLS transport from three angles
ShouldSendRecieveHTTPS1 replays the plaintext round-trip over TLS, so a
regression in the transport shows up as an HTTP failure rather than
nothing at all, and adds what only exists under TLS: ALPN, scheme=https
reaching handlers, a body spanning many records, and the two ways
verification must fail — an untrusted self-signed certificate, and a
trusted certificate presented for the wrong name. A plaintext peer
knocking on the TLS port is asserted to be counted and shrugged off.
ShouldInteropCurlHTTPS1 puts real implementations on the other end, since
two OpenSSL peers can agree on a mistake. curl verifies our certificate
with --cacert rather than --insecure, and an h2-only curl is asserted to
be refused rather than mis-served. python3's http.server behind
ssl.wrap_socket answers HTTP/1.0 with Connection: close, which frames the
body by close_notify — the path a reader is most likely to get wrong.
ShouldRequireClientCertificateHTTPS1 covers mutual TLS both ways, and
drives TLSStream directly with hand-written HTTP/1.1 to keep the :TLS
layer honest as something usable without :ClientHTTP1 on top.
Also fix a delegation that `{}` no longer disambiguates now that a
three-argument TLS constructor exists alongside the fallback one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:17:07 +00:00
|
|
|
//SPDX-License-Identifier: LGPL-3.0-only
|
|
|
|
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
|
|
|
|
|
|
// Mutual TLS on the HTTP/1.1 listener, plus the raw :TLS stream API on its
|
|
|
|
|
// own. A server that asks for a client certificate is only useful if it also
|
|
|
|
|
// refuses the peers that do not have one, so both directions are asserted.
|
|
|
|
|
//
|
|
|
|
|
// The certificate authority and client certificate are minted with the openssl
|
|
|
|
|
// CLI: a client certificate needs the clientAuth extended key usage, which the
|
|
|
|
|
// library's built-in development certificate (serverAuth, for listeners) does
|
|
|
|
|
// not carry. Without openssl the mTLS half is skipped.
|
|
|
|
|
|
|
|
|
|
import Crafter.Network;
|
|
|
|
|
import std;
|
|
|
|
|
using namespace Crafter;
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
int failures = 0;
|
|
|
|
|
|
|
|
|
|
void Check(bool condition, std::string_view what) {
|
|
|
|
|
if (!condition) {
|
|
|
|
|
std::println("FAIL: {}", what);
|
|
|
|
|
++failures;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool HaveCommand(std::string_view name) {
|
|
|
|
|
const std::string probe = "command -v " + std::string(name) + " >/dev/null 2>&1";
|
|
|
|
|
return std::system(probe.c_str()) == 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> Routes() {
|
|
|
|
|
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
|
|
|
|
|
routes["/"] = [](const HTTPRequest&) {
|
|
|
|
|
return CreateResponseHTTP("200", "authenticated");
|
|
|
|
|
};
|
|
|
|
|
return routes;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A throwaway CA and a client certificate signed by it.
|
|
|
|
|
struct ClientIdentity {
|
|
|
|
|
std::filesystem::path directory;
|
|
|
|
|
std::filesystem::path authority;
|
|
|
|
|
std::filesystem::path certificate;
|
|
|
|
|
std::filesystem::path privateKey;
|
|
|
|
|
bool ok = false;
|
|
|
|
|
|
|
|
|
|
ClientIdentity() {
|
|
|
|
|
directory = std::filesystem::temp_directory_path() / "crafter-network-mtls";
|
|
|
|
|
std::error_code error;
|
|
|
|
|
std::filesystem::remove_all(directory, error);
|
|
|
|
|
std::filesystem::create_directories(directory);
|
|
|
|
|
authority = directory / "ca.pem";
|
|
|
|
|
certificate = directory / "client.pem";
|
|
|
|
|
privateKey = directory / "client-key.pem";
|
|
|
|
|
|
|
|
|
|
const std::string extensions = (directory / "client.ext").string();
|
|
|
|
|
std::ofstream(extensions, std::ios::binary)
|
|
|
|
|
<< "basicConstraints=critical,CA:FALSE\n"
|
|
|
|
|
<< "keyUsage=critical,digitalSignature\n"
|
|
|
|
|
<< "extendedKeyUsage=clientAuth\n";
|
|
|
|
|
|
|
|
|
|
const std::string command = std::format(
|
|
|
|
|
"set -e\n"
|
|
|
|
|
"openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes"
|
|
|
|
|
" -keyout '{0}/ca-key.pem' -out '{1}' -days 5 -subj '/CN=Crafter Test CA'"
|
|
|
|
|
" -addext 'basicConstraints=critical,CA:TRUE'"
|
|
|
|
|
" -addext 'keyUsage=critical,keyCertSign'\n"
|
|
|
|
|
"openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes"
|
|
|
|
|
" -keyout '{2}' -out '{0}/client.csr' -subj '/CN=crafter-test-client'\n"
|
|
|
|
|
"openssl x509 -req -in '{0}/client.csr' -CA '{1}' -CAkey '{0}/ca-key.pem'"
|
|
|
|
|
" -set_serial 2 -days 5 -extfile '{0}/client.ext' -out '{3}'\n",
|
|
|
|
|
directory.string(), authority.string(), privateKey.string(),
|
|
|
|
|
certificate.string());
|
|
|
|
|
ok = std::system((command + " >/dev/null 2>&1").c_str()) == 0;
|
|
|
|
|
}
|
|
|
|
|
~ClientIdentity() {
|
|
|
|
|
std::error_code error;
|
|
|
|
|
std::filesystem::remove_all(directory, error);
|
|
|
|
|
}
|
|
|
|
|
ClientIdentity(const ClientIdentity&) = delete;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// The :TLS layer without any HTTP on top — the case for anything else that
|
|
|
|
|
// owns a connected socket and wants a record layer over it.
|
|
|
|
|
void RawStreamAgainstListener() {
|
2026-07-28 20:34:23 +00:00
|
|
|
ListenerAsyncHTTP1 listener(8113, Routes(), TLSServerCredentials{ .selfSigned = true });
|
test(https): cover the TLS transport from three angles
ShouldSendRecieveHTTPS1 replays the plaintext round-trip over TLS, so a
regression in the transport shows up as an HTTP failure rather than
nothing at all, and adds what only exists under TLS: ALPN, scheme=https
reaching handlers, a body spanning many records, and the two ways
verification must fail — an untrusted self-signed certificate, and a
trusted certificate presented for the wrong name. A plaintext peer
knocking on the TLS port is asserted to be counted and shrugged off.
ShouldInteropCurlHTTPS1 puts real implementations on the other end, since
two OpenSSL peers can agree on a mistake. curl verifies our certificate
with --cacert rather than --insecure, and an h2-only curl is asserted to
be refused rather than mis-served. python3's http.server behind
ssl.wrap_socket answers HTTP/1.0 with Connection: close, which frames the
body by close_notify — the path a reader is most likely to get wrong.
ShouldRequireClientCertificateHTTPS1 covers mutual TLS both ways, and
drives TLSStream directly with hand-written HTTP/1.1 to keep the :TLS
layer honest as something usable without :ClientHTTP1 on top.
Also fix a delegation that `{}` no longer disambiguates now that a
three-argument TLS constructor exists alongside the fallback one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:17:07 +00:00
|
|
|
|
|
|
|
|
TLSClientCredentials credentials;
|
|
|
|
|
credentials.caPem = GetSelfSignedCertificatePem().certificate;
|
|
|
|
|
auto context = TLSContext::Client(credentials);
|
|
|
|
|
|
2026-07-28 20:34:23 +00:00
|
|
|
ClientTCP socket("localhost", 8113);
|
test(https): cover the TLS transport from three angles
ShouldSendRecieveHTTPS1 replays the plaintext round-trip over TLS, so a
regression in the transport shows up as an HTTP failure rather than
nothing at all, and adds what only exists under TLS: ALPN, scheme=https
reaching handlers, a body spanning many records, and the two ways
verification must fail — an untrusted self-signed certificate, and a
trusted certificate presented for the wrong name. A plaintext peer
knocking on the TLS port is asserted to be counted and shrugged off.
ShouldInteropCurlHTTPS1 puts real implementations on the other end, since
two OpenSSL peers can agree on a mistake. curl verifies our certificate
with --cacert rather than --insecure, and an h2-only curl is asserted to
be refused rather than mis-served. python3's http.server behind
ssl.wrap_socket answers HTTP/1.0 with Connection: close, which frames the
body by close_notify — the path a reader is most likely to get wrong.
ShouldRequireClientCertificateHTTPS1 covers mutual TLS both ways, and
drives TLSStream directly with hand-written HTTP/1.1 to keep the :TLS
layer honest as something usable without :ClientHTTP1 on top.
Also fix a delegation that `{}` no longer disambiguates now that a
three-argument TLS constructor exists alongside the fallback one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:17:07 +00:00
|
|
|
std::unique_ptr<TLSStream> stream =
|
|
|
|
|
TLSStream::Connect(socket.socketid, context, "localhost",
|
|
|
|
|
std::chrono::seconds(5));
|
|
|
|
|
|
|
|
|
|
Check(stream->Secure(), "a TLSStream reports itself as secure");
|
|
|
|
|
Check(stream->Protocol() == "http/1.1", "the raw stream negotiated ALPN");
|
|
|
|
|
Check(stream->Version().starts_with("TLS"), "a TLS version was negotiated");
|
|
|
|
|
// The listener presents the development certificate, whose subject is
|
|
|
|
|
// CN=localhost.
|
|
|
|
|
Check(stream->PeerCertificateSubject().find("localhost") != std::string::npos,
|
|
|
|
|
"the server certificate subject is readable");
|
|
|
|
|
|
|
|
|
|
// Hand-written HTTP/1.1 straight down the stream, to prove the record
|
|
|
|
|
// layer is usable on its own.
|
|
|
|
|
const std::string request = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
|
|
|
|
stream->Write(request.data(), request.size(), std::chrono::seconds(5));
|
|
|
|
|
|
|
|
|
|
HTTP1::MessageParser parser(HTTP1::MessageKind::Response);
|
|
|
|
|
std::vector<char> chunk(4096);
|
|
|
|
|
while (!parser.Complete()) {
|
|
|
|
|
std::size_t read = 0;
|
|
|
|
|
const StreamStatus status =
|
|
|
|
|
stream->ReadSome(chunk.data(), chunk.size(), std::chrono::seconds(5), read);
|
|
|
|
|
if (status != StreamStatus::Data) break;
|
|
|
|
|
parser.Feed(chunk.data(), read);
|
|
|
|
|
}
|
|
|
|
|
Check(parser.Complete(), "a response arrived over the raw TLS stream");
|
|
|
|
|
if (parser.Complete()) {
|
|
|
|
|
HTTPResponse response = parser.TakeResponse();
|
|
|
|
|
Check(response.status == "200", "raw TLS stream response status");
|
|
|
|
|
Check(response.body == "authenticated", "raw TLS stream response body");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
stream.reset();
|
|
|
|
|
listener.Stop();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MutualTLS() {
|
|
|
|
|
if (!HaveCommand("openssl")) {
|
|
|
|
|
std::println("skipping the mTLS half: openssl is not installed");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
ClientIdentity identity;
|
|
|
|
|
if (!identity.ok) {
|
|
|
|
|
std::println("skipping the mTLS half: could not mint a client certificate");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TLSServerCredentials server;
|
|
|
|
|
server.selfSigned = true;
|
|
|
|
|
server.requireClientCertificate = true;
|
|
|
|
|
server.clientCaPath = identity.authority.string();
|
2026-07-28 20:34:23 +00:00
|
|
|
ListenerAsyncHTTP1 listener(8114, Routes(), server);
|
test(https): cover the TLS transport from three angles
ShouldSendRecieveHTTPS1 replays the plaintext round-trip over TLS, so a
regression in the transport shows up as an HTTP failure rather than
nothing at all, and adds what only exists under TLS: ALPN, scheme=https
reaching handlers, a body spanning many records, and the two ways
verification must fail — an untrusted self-signed certificate, and a
trusted certificate presented for the wrong name. A plaintext peer
knocking on the TLS port is asserted to be counted and shrugged off.
ShouldInteropCurlHTTPS1 puts real implementations on the other end, since
two OpenSSL peers can agree on a mistake. curl verifies our certificate
with --cacert rather than --insecure, and an h2-only curl is asserted to
be refused rather than mis-served. python3's http.server behind
ssl.wrap_socket answers HTTP/1.0 with Connection: close, which frames the
body by close_notify — the path a reader is most likely to get wrong.
ShouldRequireClientCertificateHTTPS1 covers mutual TLS both ways, and
drives TLSStream directly with hand-written HTTP/1.1 to keep the :TLS
layer honest as something usable without :ClientHTTP1 on top.
Also fix a delegation that `{}` no longer disambiguates now that a
three-argument TLS constructor exists alongside the fallback one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:17:07 +00:00
|
|
|
|
|
|
|
|
// A client with a certificate the listener's CA vouches for.
|
|
|
|
|
{
|
|
|
|
|
TLSClientCredentials credentials;
|
|
|
|
|
credentials.caPem = GetSelfSignedCertificatePem().certificate;
|
|
|
|
|
credentials.certPath = identity.certificate.string();
|
|
|
|
|
credentials.keyPath = identity.privateKey.string();
|
2026-07-28 20:34:23 +00:00
|
|
|
ClientHTTP1 client("localhost", 8114, credentials);
|
test(https): cover the TLS transport from three angles
ShouldSendRecieveHTTPS1 replays the plaintext round-trip over TLS, so a
regression in the transport shows up as an HTTP failure rather than
nothing at all, and adds what only exists under TLS: ALPN, scheme=https
reaching handlers, a body spanning many records, and the two ways
verification must fail — an untrusted self-signed certificate, and a
trusted certificate presented for the wrong name. A plaintext peer
knocking on the TLS port is asserted to be counted and shrugged off.
ShouldInteropCurlHTTPS1 puts real implementations on the other end, since
two OpenSSL peers can agree on a mistake. curl verifies our certificate
with --cacert rather than --insecure, and an h2-only curl is asserted to
be refused rather than mis-served. python3's http.server behind
ssl.wrap_socket answers HTTP/1.0 with Connection: close, which frames the
body by close_notify — the path a reader is most likely to get wrong.
ShouldRequireClientCertificateHTTPS1 covers mutual TLS both ways, and
drives TLSStream directly with hand-written HTTP/1.1 to keep the :TLS
layer honest as something usable without :ClientHTTP1 on top.
Also fix a delegation that `{}` no longer disambiguates now that a
three-argument TLS constructor exists alongside the fallback one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:17:07 +00:00
|
|
|
|
|
|
|
|
HTTPResponse response = client.Send(CreateRequestHTTP("GET", "/", "localhost"));
|
|
|
|
|
Check(response.status == "200", "a client with a trusted certificate is served");
|
|
|
|
|
Check(response.body == "authenticated", "mTLS response body");
|
|
|
|
|
}
|
|
|
|
|
Check(listener.listener.HandshakeFailureCount() == 0,
|
|
|
|
|
"a valid client certificate is not a handshake failure");
|
|
|
|
|
|
|
|
|
|
// The same client, with no certificate at all. Under TLS 1.3 the
|
|
|
|
|
// server's rejection arrives after the client believes the handshake
|
|
|
|
|
// finished, so the failure can surface at connect *or* on the first
|
|
|
|
|
// exchange — either is a refusal, and neither may be a success.
|
|
|
|
|
{
|
|
|
|
|
TLSClientCredentials credentials;
|
|
|
|
|
credentials.caPem = GetSelfSignedCertificatePem().certificate;
|
2026-07-28 20:34:23 +00:00
|
|
|
ClientHTTP1 anonymous("localhost", 8114, credentials);
|
test(https): cover the TLS transport from three angles
ShouldSendRecieveHTTPS1 replays the plaintext round-trip over TLS, so a
regression in the transport shows up as an HTTP failure rather than
nothing at all, and adds what only exists under TLS: ALPN, scheme=https
reaching handlers, a body spanning many records, and the two ways
verification must fail — an untrusted self-signed certificate, and a
trusted certificate presented for the wrong name. A plaintext peer
knocking on the TLS port is asserted to be counted and shrugged off.
ShouldInteropCurlHTTPS1 puts real implementations on the other end, since
two OpenSSL peers can agree on a mistake. curl verifies our certificate
with --cacert rather than --insecure, and an h2-only curl is asserted to
be refused rather than mis-served. python3's http.server behind
ssl.wrap_socket answers HTTP/1.0 with Connection: close, which frames the
body by close_notify — the path a reader is most likely to get wrong.
ShouldRequireClientCertificateHTTPS1 covers mutual TLS both ways, and
drives TLSStream directly with hand-written HTTP/1.1 to keep the :TLS
layer honest as something usable without :ClientHTTP1 on top.
Also fix a delegation that `{}` no longer disambiguates now that a
three-argument TLS constructor exists alongside the fallback one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:17:07 +00:00
|
|
|
bool refused = false;
|
|
|
|
|
try {
|
|
|
|
|
anonymous.Send(CreateRequestHTTP("GET", "/", "localhost"));
|
|
|
|
|
} catch (const std::exception&) {
|
|
|
|
|
refused = true;
|
|
|
|
|
}
|
|
|
|
|
Check(refused, "a client with no certificate is refused");
|
|
|
|
|
}
|
|
|
|
|
for (int wait = 0; wait < 100; ++wait) {
|
|
|
|
|
if (listener.listener.HandshakeFailureCount() > 0) break;
|
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
|
|
|
|
}
|
|
|
|
|
Check(listener.listener.HandshakeFailureCount() == 1,
|
|
|
|
|
"the refused client is counted as a handshake failure");
|
|
|
|
|
|
|
|
|
|
listener.Stop();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int main() {
|
|
|
|
|
try {
|
|
|
|
|
RawStreamAgainstListener();
|
|
|
|
|
MutualTLS();
|
|
|
|
|
} catch (const std::exception& error) {
|
|
|
|
|
std::println("threw: {}", error.what());
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (failures != 0) {
|
|
|
|
|
std::println("{} check(s) failed", failures);
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
}
|