From 5b2cad58ffa4b3b8ad049fadfc273d2fdacfeda8 Mon Sep 17 00:00:00 2001 From: Denys Fedoryshchenko Date: Sun, 7 Jul 2024 12:21:10 +0300 Subject: [PATCH 1/2] https: New option to validate, CertPubKey This option validates signature of remote certificate. It is always possible that malicious actor might put MITM node and obtain his own certificate. In fact such attack already happened, one of well documented cases: https://notes.valdikss.org.ru/jabber.ru-mitm/ The only way to detect such malicious intent is by validating certificate public key. Signed-off-by: Denys Fedoryshchenko --- netmon.cc | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++- simplomon.hh | 1 + 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/netmon.cc b/netmon.cc index 9f4cb52..cc63d35 100644 --- a/netmon.cc +++ b/netmon.cc @@ -60,11 +60,20 @@ CheckResult TCPPortClosedChecker::perform() return cr; } +std::vector split(const std::string& s, char delimiter) +{ + std::vector tokens; + std::string token; + std::istringstream tokenStream(s); + while (std::getline(tokenStream, token, delimiter)) + tokens.push_back(token); + return tokens; +} // XXX needs switch to select IPv4 or IPv6 or happy eyeballs? HTTPSChecker::HTTPSChecker(sol::table data) : Checker(data) { - checkLuaTable(data, {"url"}, {"maxAgeMinutes", "minBytes", "minCertDays", "serverIP", "method", "localIP4", "localIP6", "dns", "regex"}); + checkLuaTable(data, {"url"}, {"maxAgeMinutes", "minBytes", "minCertDays", "serverIP", "method", "localIP4", "localIP6", "dns", "regex", "CertPubKey"}); d_url = data.get("url"); d_maxAgeMinutes =data.get_or("maxAgeMinutes", 0); d_minCertDays = data.get_or("minCertDays", 14); @@ -76,6 +85,7 @@ HTTPSChecker::HTTPSChecker(sol::table data) : Checker(data) d_method = data.get_or("method", string("GET")); vector dns = data.get_or("dns", vector()); d_regexStr = data.get_or("regex", string("")); + d_cert_pubkey = data.get_or("CertPubKey", string("")); d_attributes["url"] = d_url; d_attributes["method"] = d_method; @@ -95,6 +105,26 @@ HTTPSChecker::HTTPSChecker(sol::table data) : Checker(data) d_attributes["localIP6"] = d_localIP6->toString(); } + // Validate CertPubKey + // It should be 3 parts, separated by : (For now we only support RSA keys) + // RSA:RSA(e):RSA(n) + if (!d_cert_pubkey.empty()) { + // Count how many : + size_t count = std::count(d_cert_pubkey.begin(), d_cert_pubkey.end(), ':'); + vector parts = split(d_cert_pubkey, ':'); + // At least one : should be there + if (count == 0) { + throw runtime_error(fmt::format("Invalid CertPubKey '{}', should be KEYTYPE:KEYCOMPONENTS", d_cert_pubkey)); + } + // As soon as ECDSA appear in certinfo we will add more components + if (parts[0] == "RSA") { + if (count != 2) + throw runtime_error(fmt::format("Invalid RSA CertPubKey '{}', should be RSA:RSA(e):RSA(n)", d_cert_pubkey)); + } else { + throw runtime_error(fmt::format("Only RSA keys are supported, not '{}'", parts[0])); + } + } + if(!dns.empty()) { for(const auto& d : dns) @@ -127,6 +157,7 @@ CheckResult HTTPSChecker::perform() activeServerIP4.sin4.sin_family = 0; // "unset" activeServerIP6.sin4.sin_family = 0; // "unset" double dnsMsec4 = 0, dnsMsec6 = 0; + bool signatureOK = false; DNSName qname = makeDNSName(extractHostFromURL(d_url)); @@ -254,6 +285,23 @@ CheckResult HTTPSChecker::perform() struct tm tm={}; // Jul 29 00:00:00 2023 GMT + // check if any of certs in chain Signature match d_cert_pubkey + if(!d_cert_pubkey.empty()) { + vector parts = split(d_cert_pubkey, ':'); + if (parts[0] == "RSA") { + // Verify if we have rsa(e) and rsa(n) in the cert + if (cert.second.find("rsa(e)") != cert.second.end() && cert.second.find("rsa(n)") != cert.second.end()) { + // Build certificate Signature string: RSA:$rsa(e):$rsa(n) + string certSignature = fmt::format("RSA:{}:{}", cert.second["rsa(e)"], cert.second["rsa(n)"]); + // debug print + //fmt::print("Cert Signature: {}\n", certSignature); + if (certSignature.compare(d_cert_pubkey) == 0) { + signatureOK = true; + } + } + } + } + strptime(cert.second["Expire date"].c_str(), "%b %d %H:%M:%S %Y", &tm); time_t expire = mktime(&tm); strptime(cert.second["Start date"].c_str(), "%b %d %H:%M:%S %Y", &tm); @@ -276,6 +324,11 @@ CheckResult HTTPSChecker::perform() d_url, (int)round(days), serverIP)); return; } + if (!d_cert_pubkey.empty() && !signatureOK) { + cr.d_reasons[subject].push_back(fmt::format("A certificate for '{}' does not have the expected pubkey '{}'", + d_url, d_cert_pubkey)); + return; + } } catch(exception& e) { cr.d_reasons[subject].push_back(e.what() + serverIP); diff --git a/simplomon.hh b/simplomon.hh index 45cbcf2..56a7786 100644 --- a/simplomon.hh +++ b/simplomon.hh @@ -239,6 +239,7 @@ private: std::string d_method; std::string d_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"; + std::string d_cert_pubkey; }; From d1db9c4aba6edccbcc0c4d2a2ee2cf58453293c5 Mon Sep 17 00:00:00 2001 From: Denys Fedoryshchenko Date: Sun, 7 Jul 2024 12:26:50 +0300 Subject: [PATCH 2/2] CertPubKey: add corresponding manual entry Signed-off-by: Denys Fedoryshchenko --- manual.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manual.md b/manual.md index dfc503e..0202642 100644 --- a/manual.md +++ b/manual.md @@ -126,6 +126,14 @@ Here are the parameters, of which only `url` is mandatory: HEAD, possibly because of "web firewalls" * dns: get the IP address from these nameservers. Useful when testing against DNS-based CDNs (like Akamai). + * CertPubKey: Remote certificate public key. If set, the checker will alert + if the public key of the certificate does not match this value. + Format is tuples divided by colon, where is first tuple is the + key type (RSA, ECDSA), remaining tuples depend on the key type. For RSA + keys, the second tuple is the exponent, the third tuple is the modulus (both in hex). + For example: + CertPubKey="RSA:10001:HEXLONGSTRING" + ## imap The imap checker assumes it connects to a TLS endpoint. There it will check the certificate for freshness.