Crafter.Network/tests/ShouldRequireClientCertificateHTTPS1/main.cpp

204 lines
8.5 KiB
C++
Raw Normal View History

//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() {
ListenerAsyncHTTP1 listener(8099, Routes(), TLSServerCredentials{ .selfSigned = true });
TLSClientCredentials credentials;
credentials.caPem = GetSelfSignedCertificatePem().certificate;
auto context = TLSContext::Client(credentials);
ClientTCP socket("localhost", 8099);
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();
ListenerAsyncHTTP1 listener(8100, Routes(), server);
// 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();
ClientHTTP1 client("localhost", 8100, credentials);
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;
ClientHTTP1 anonymous("localhost", 8100, credentials);
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;
}