diff options
| author | saturneric <[email protected]> | 2026-08-01 15:28:15 +0200 |
|---|---|---|
| committer | saturneric <[email protected]> | 2026-08-01 15:28:15 +0200 |
| commit | c63a463418a49edc6f501bee97fd1d50914dd5ed (patch) | |
| tree | 0740cabefcebe868080c077c3d83dbbe7962c078 /src/m_key_server_sync/KeyServerProbe.cpp | |
| parent | i18n(key-server-sync): update translations (diff) | |
| download | modules-c63a463418a49edc6f501bee97fd1d50914dd5ed.tar.gz modules-c63a463418a49edc6f501bee97fd1d50914dd5ed.zip | |
feat(key_server_sync): add key server settings
- add key server settings UI and settings page for managing servers
- implement KeyServerList to load, store, and resolve default server
- add KeyServerProber to probe HKP and VKS and classify replies
- register settings page and expose it to host settings dialog
- require VKSInterface to be constructed with target server
- add shared request builder with user agent and transfer timeout
- update callers to pass server and include used server in responses
- adapt SearchKeyDialog to use configured server list and default
- unregister settings page on module deactivate to avoid crashes
Diffstat (limited to 'src/m_key_server_sync/KeyServerProbe.cpp')
| -rw-r--r-- | src/m_key_server_sync/KeyServerProbe.cpp | 256 |
1 files changed, 256 insertions, 0 deletions
diff --git a/src/m_key_server_sync/KeyServerProbe.cpp b/src/m_key_server_sync/KeyServerProbe.cpp new file mode 100644 index 0000000..42dc074 --- /dev/null +++ b/src/m_key_server_sync/KeyServerProbe.cpp @@ -0,0 +1,256 @@ +/** + * Copyright (C) 2021-2024 Saturneric <[email protected]> + * + * This file is part of GpgFrontend. + * + * GpgFrontend is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * GpgFrontend is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with GpgFrontend. If not, see <https://www.gnu.org/licenses/>. + * + * The initial version of the source code is inherited from + * the gpg4usb project, which is under GPL-3.0-or-later. + * + * All the source code of GpgFrontend was modified and released by + * Saturneric <[email protected]> starting on May 12, 2021. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +#include "KeyServerProbe.h" + +#include <GFSDKExtra.h> + +#include <QCoreApplication> +#include <QJsonDocument> +#include <QNetworkAccessManager> +#include <QNetworkRequest> +#include <QRegularExpression> +#include <optional> + +#include "GFModuleCommonUtils.hpp" + +namespace { + +/// A handle that cannot belong to any key, so a conforming server is guaranteed +/// to take its "no such key" path rather than returning something real. +constexpr auto kAbsentHandle = "0000000000000000000000000000000000000000"; + +/// Long enough for any keyserver's "not found" note, short enough that a real +/// document does not slip through as one. +constexpr int kMaxNotFoundBodySize = 512; + +/// Only the head of a response tells us anything; the rest is not worth +/// holding. +constexpr int kMaxInspectedBodySize = 4096; + +/// Shorter than a real lookup's 15s: this runs while the user waits on a +/// dialog. +constexpr int kProbeTimeoutMs = 8000; + +/** + * @brief Whether the response body is an actual HTML document. + * + * Judged on the body rather than the Content-Type header, because the header + * cannot be trusted here: keys.openpgp.org answers its "No key found for + * fingerprint ..." with `text/html`, even though the body is a single line of + * plain text. Rejecting on the header alone would throw out one of the most + * widely used key servers there is. + * + * A mislabelled one-liner and a real 404 page are still easy to tell apart — + * the page is a document, and it says so in its first bytes. + */ +auto LooksLikeHtml(const QString& content_type, const QByteArray& body) + -> bool { + const auto head = QString::fromLatin1(body.left(64)).trimmed().toLower(); + if (head.startsWith("<!doctype html") || head.startsWith("<html")) { + return true; + } + + // Claims to be a page, and is long enough to actually be one: markup that + // opens with a comment or a stray BOM would slip past the check above. + return content_type.contains("text/html") && + body.trimmed().size() >= kMaxNotFoundBodySize; +} + +auto ParsesAsJsonObject(const QByteArray& body) -> bool { + return QJsonDocument::fromJson(body.trimmed()).isObject(); +} + +/** + * @brief The verdicts that do not depend on which protocol was probed. + * + * @return std::optional<ProbeVerdict> set when the shared rules already decide + */ +auto ClassifyShared(int network_error, int http_status, + const QString& content_type, const QByteArray& body) + -> std::optional<ProbeVerdict> { + // No status at all means we never spoke HTTP with anyone: DNS failure, + // refused connection, TLS handshake, or our own transfer timeout firing. + if (http_status == 0) { + return network_error == QNetworkReply::NoError + ? std::optional<ProbeVerdict>{} + : ProbeVerdict::kUnreachable; + } + + // An HTML document is what separates a website's 404 from a key server's. It + // also catches captive portals and intercepting proxies, which happily answer + // 200 with a login page for any URL you ask them about. + if (LooksLikeHtml(content_type, body)) return ProbeVerdict::kNotAKeyServer; + + return {}; +} + +} // namespace + +auto ClassifyHkpProbe(int network_error, int http_status, + const QString& content_type, const QByteArray& body) + -> ProbeVerdict { + const auto lower_type = content_type.toLower(); + if (auto shared = + ClassifyShared(network_error, http_status, lower_type, body)) { + return *shared; + } + + const auto trimmed = body.trimmed(); + + if (http_status == 200) { + // The machine-readable index starts with an info: header, and each key it + // found is a pub: line. + static const QRegularExpression kInfoLine(R"(^info:\s*\d)"); + const auto text = QString::fromUtf8(trimmed.left(kMaxInspectedBodySize)); + if (kInfoLine.match(text).hasMatch()) return ProbeVerdict::kConforms; + if (text.startsWith("pub:") || text.contains("\npub:")) { + return ProbeVerdict::kConforms; + } + // Some deployments answer an empty index rather than a 404. + if (trimmed.isEmpty()) return ProbeVerdict::kConforms; + return ProbeVerdict::kNotAKeyServer; + } + + // "No keys found" is the expected answer here, and HKP servers say it with a + // 404 (or a 501 for an op they do not implement) and a one-line body. + if ((http_status == 404 || http_status == 501) && + trimmed.size() < kMaxNotFoundBodySize) { + return ProbeVerdict::kConforms; + } + + return ProbeVerdict::kNotAKeyServer; +} + +auto ClassifyVksProbe(int network_error, int http_status, + const QString& content_type, const QByteArray& body) + -> ProbeVerdict { + const auto lower_type = content_type.toLower(); + if (auto shared = + ClassifyShared(network_error, http_status, lower_type, body)) { + return *shared; + } + + const auto trimmed = body.trimmed(); + + if (http_status == 200) { + return trimmed.startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----") + ? ProbeVerdict::kConforms + : ProbeVerdict::kNotAKeyServer; + } + + // VKS reports both a missing key and a malformed handle as a JSON error + // object; keys.openpgp.org answers {"code":404,"message":"..."}. + if (http_status == 404 || http_status == 400) { + if (lower_type.contains("application/json") || + ParsesAsJsonObject(trimmed)) { + return ProbeVerdict::kConforms; + } + // A plain-text "not found" is still a key server answering; only a 404 with + // a whole document behind it is something else. + if (http_status == 404 && trimmed.size() < kMaxNotFoundBodySize) { + return ProbeVerdict::kConforms; + } + } + + return ProbeVerdict::kNotAKeyServer; +} + +KeyServerProber::KeyServerProber(QObject* parent) + : QObject(parent), manager_(new QNetworkAccessManager(this)) {} + +void KeyServerProber::Probe(const QString& url) { + result_ = Result{}; + result_.url = url; + outstanding_ = 2; + + send(url, true); + send(url, false); +} + +void KeyServerProber::send(const QString& url, bool is_hkp) { + const auto target = + is_hkp ? QString("%1/pks/lookup?op=index&options=mr&search=0x%2") + .arg(url, kAbsentHandle) + : QString("%1/vks/v1/by-fingerprint/%2").arg(url, kAbsentHandle); + + QNetworkRequest request{QUrl(target)}; + request.setHeader(QNetworkRequest::UserAgentHeader, + UDUP(GFHttpRequestUserAgent())); + request.setTransferTimeout(kProbeTimeoutMs); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + + auto* reply = manager_->get(request); + connect(reply, &QNetworkReply::finished, this, [this, reply, is_hkp]() { + const auto status = + reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const auto content_type = + reply->header(QNetworkRequest::ContentTypeHeader).toString(); + const auto body = reply->read(kMaxInspectedBodySize); + + FLOG_DEBUG("key server probe reply, hkp: %1, status: %2, error: %3", + static_cast<int>(is_hkp), status, + static_cast<int>(reply->error())); + + settle(is_hkp, is_hkp ? ClassifyHkpProbe(static_cast<int>(reply->error()), + status, content_type, body) + : ClassifyVksProbe(static_cast<int>(reply->error()), + status, content_type, body)); + reply->deleteLater(); + }); +} + +void KeyServerProber::settle(bool is_hkp, ProbeVerdict verdict) { + if (is_hkp) { + hkp_verdict_ = verdict; + result_.hkp = verdict == ProbeVerdict::kConforms; + } else { + vks_verdict_ = verdict; + result_.vks = verdict == ProbeVerdict::kConforms; + } + + if (--outstanding_ > 0) return; + + if (!result_.Conforms()) { + // Say which of the two things went wrong. Being unreachable is worth + // separating from answering wrongly: one is worth retrying later, the other + // means the address is not a key server at all. + const auto unreachable = hkp_verdict_ == ProbeVerdict::kUnreachable && + vks_verdict_ == ProbeVerdict::kUnreachable; + result_.detail = + unreachable ? QCoreApplication::translate( + "GTrC", "The server could not be reached.") + : QCoreApplication::translate( + "GTrC", + "The server responded, but not as a key server: it " + "supports neither the HKP nor the VKS interface."); + } + + emit SignalProbeFinished(result_); +} |
