#include "certs/pem_info.hpp" #include #include #include #include #include #include #include #include #include #include namespace smartbotic::certs { using common::Error; using common::ErrorCode; using common::Result; namespace { std::string nameToString(X509_NAME* name) { if (!name) { return {}; } // A BIO rather than X509_NAME_oneline: oneline mangles UTF-8 into escapes, // and these strings are shown to a person. std::unique_ptr bio(BIO_new(BIO_s_mem()), BIO_free); if (!bio) { return {}; } if (X509_NAME_print_ex(bio.get(), name, 0, XN_FLAG_RFC2253) < 0) { return {}; } char* data = nullptr; const long len = BIO_get_mem_data(bio.get(), &data); if (len <= 0 || !data) { return {}; } return std::string(data, static_cast(len)); } // ASN1 time to milliseconds since the epoch. Returns 0 when it cannot be read, // which the caller reports as "unknown" rather than as 1970. int64_t asn1TimeToMs(const ASN1_TIME* when) { if (!when) { return 0; } struct tm tm_value{}; if (ASN1_TIME_to_tm(when, &tm_value) != 1) { return 0; } // timegm, not mktime: certificate times are UTC, and mktime would apply // whatever timezone the server happens to be in. const time_t seconds = timegm(&tm_value); if (seconds == static_cast(-1)) { return 0; } return static_cast(seconds) * 1000; } std::string sha256Fingerprint(X509* cert) { unsigned char digest[EVP_MAX_MD_SIZE]; unsigned int length = 0; if (X509_digest(cert, EVP_sha256(), digest, &length) != 1) { return {}; } static const char* kHex = "0123456789ABCDEF"; std::string out; out.reserve(length * 3); for (unsigned int i = 0; i < length; ++i) { if (i > 0) { out.push_back(':'); } out.push_back(kHex[digest[i] >> 4]); out.push_back(kHex[digest[i] & 0x0F]); } return out; } std::string toLowerTrimmed(std::string value) { // A trailing dot names the same host - "example.com." is the fully // qualified spelling of "example.com" and must not miss a match. while (!value.empty() && value.back() == '.') { value.pop_back(); } std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); return value; } } // namespace nlohmann::json CertificateInfo::toJson() const { return { {"subject", subject}, {"issuer", issuer}, {"notBefore", not_before_ms}, {"notAfter", not_after_ms}, {"fingerprintSha256", fingerprint_sha256}, {"isCa", is_ca}, }; } Result> parsePem(const std::string& pem) { if (pem.empty()) { return Error(ErrorCode::InvalidArgument, "The certificate is empty"); } std::unique_ptr bio( BIO_new_mem_buf(pem.data(), static_cast(pem.size())), BIO_free); if (!bio) { return Error(ErrorCode::Internal, "Could not read the certificate"); } std::vector out; while (true) { X509* raw = PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr); if (!raw) { break; } std::unique_ptr cert(raw, X509_free); CertificateInfo info; info.subject = nameToString(X509_get_subject_name(cert.get())); info.issuer = nameToString(X509_get_issuer_name(cert.get())); info.not_before_ms = asn1TimeToMs(X509_get0_notBefore(cert.get())); info.not_after_ms = asn1TimeToMs(X509_get0_notAfter(cert.get())); info.fingerprint_sha256 = sha256Fingerprint(cert.get()); info.is_ca = X509_check_ca(cert.get()) > 0; out.push_back(std::move(info)); } // Whatever PEM_read_bio_X509 stopped on is on the error stack. It is not // reported: stopping is how the loop ends normally, at the end of the // bundle. Leaving it there would poison the next OpenSSL call in this // thread with an error it did not cause. ERR_clear_error(); if (out.empty()) { return Error(ErrorCode::InvalidArgument, "No certificate found. It has to be PEM, starting with " "-----BEGIN CERTIFICATE----- . A DER or PKCS#12 file has to be " "converted first, and a private key is not a certificate."); } return out; } bool hostMatches(const std::string& host, const std::vector& patterns) { const std::string needle = toLowerTrimmed(host); if (needle.empty()) { return false; } for (const auto& raw_pattern : patterns) { const std::string pattern = toLowerTrimmed(raw_pattern); if (pattern.empty()) { continue; } if (pattern.rfind("*.", 0) == 0) { // One leading label, and only one - the same rule certificate // wildcards follow, so "*.fsociety.hu" covers api.fsociety.hu and // neither fsociety.hu nor a.b.fsociety.hu. const std::string suffix = pattern.substr(1); // keeps the dot if (needle.size() <= suffix.size()) { continue; } if (needle.compare(needle.size() - suffix.size(), suffix.size(), suffix) != 0) { continue; } const std::string label = needle.substr(0, needle.size() - suffix.size()); if (label.empty() || label.find('.') != std::string::npos) { continue; } return true; } if (needle == pattern) { return true; } } return false; } } // namespace smartbotic::certs