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. 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; };