aboutsummaryrefslogtreecommitdiffstats
path: root/src/m_email/EMailTlsSetup.cpp
blob: 1ee477198d70b3ee7d5fd008e8d802a654274695 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
/**
 * Copyright (C) 2021-2024 Saturneric <eric@bktus.com>
 *
 * 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 <eric@bktus.com> starting on May 12, 2021.
 *
 * SPDX-License-Identifier: GPL-3.0-or-later
 *
 */

#include "EMailTlsSetup.h"

#include <QDate>
#include <QMutex>
#include <QMutexLocker>
#include <QSslCertificate>
#include <QSslConfiguration>
#include <vmime/net/tls/TLSSecuredConnectionInfos.hpp>
#include <vmime/security/cert/defaultCertificateVerifier.hpp>

#include "GFModule.h"

namespace cert = vmime::security::cert;

namespace {

Q_GLOBAL_STATIC(QMutex, tls_mutex)

/// Parsed once per process. Turning the system trust store into vmime
/// certificates costs real time -- there are typically well over a hundred --
/// and it cannot change while we are running.
auto RootCertificates()
    -> const std::vector<vmime::shared_ptr<cert::X509Certificate>>& {
  static std::vector<vmime::shared_ptr<cert::X509Certificate>> roots;
  static bool loaded = false;

  QMutexLocker locker(tls_mutex());
  if (loaded) return roots;
  loaded = true;

  // Qt's system store is the only source that works on all three platforms:
  // the Windows and macOS stores have no file to read, and a bundled CA list
  // would make us responsible for distrust events we cannot react to quickly.
  const auto system_certs = QSslConfiguration::systemCaCertificates();

  // vmime's batch import is PEM-only in practice -- it loops on
  // PEM_read_bio_X509 and stops at the first failure -- despite its comment
  // claiming it accepts DER as well.
  QByteArray bundle;
  for (const auto& certificate : system_certs) {
    bundle.append(certificate.toPem());
  }

  if (bundle.isEmpty()) {
    LOG_ERROR("system certificate store is empty; mail TLS will fail closed");
    return roots;
  }

  cert::X509Certificate::import(
      reinterpret_cast<const vmime::byte_t*>(bundle.constData()),
      static_cast<size_t>(bundle.size()), roots);

  // %1, not %d: FormatString() is built on QString::arg, so a printf-style
  // placeholder is left in the text and the argument is dropped with a
  // runtime warning.
  FLOG_DEBUG("loaded %1 root certificates for mail TLS",
             static_cast<int>(roots.size()));
  return roots;
}

/// The certificate the last failed verification saw, so it can be shown and
/// offered for pinning. Guarded because a worker thread writes it and the GUI
/// thread reads it.
auto FingerprintOf(const vmime::shared_ptr<cert::X509Certificate>& certificate)
    -> QString {
  if (!certificate) return {};

  // vmime hands back the fingerprint ALREADY FORMATTED, as the ASCII text
  // "AB:CD:EF:..." rather than as the raw digest bytes
  // (X509Certificate_OpenSSL.cpp:571-592). Hex-encoding that a second time --
  // which is what this used to do -- produced a string twice the right length
  // that matched nothing and meant nothing, so no pin could ever apply and
  // the fingerprint shown to the user was not the certificate's.
  const auto digest =
      certificate->getFingerprint(cert::X509Certificate::DIGEST_SHA256);
  QByteArray bytes;
  for (const auto byte : digest) bytes.append(static_cast<char>(byte));
  return QString::fromLatin1(bytes).remove(':').trimmed().toLower();
}

/**
 * @brief The application's verifier: the default one, plus a memory.
 *
 * It deliberately does NOT relax any rule. Pinning is expressed by handing the
 * pinned certificate to the base class as a trusted certificate, which the
 * base class checks only after validity and before hostname -- so a pinned
 * certificate that has expired, or that names another host, still fails. That
 * ordering is what keeps a pin from becoming "ignore TLS errors".
 *
 * Covered by EMailNetTest: an untrusted chain with no pin is refused, a wrong
 * pin does not rescue it, and the right one is accepted. The expiry and
 * hostname orderings are NOT covered -- a comment here used to claim they
 * were, which is worse than saying nothing, because it is the kind of claim
 * someone relies on instead of writing the test.
 */
class RememberingVerifier : public cert::defaultCertificateVerifier {
 public:
  /// @param pin lowercase hex SHA-256 of the one certificate this transport
  ///   trusts beyond the system roots, or empty for "system roots only".
  /// @param seen where to record what this connection was offered. Owned by
  ///   the caller and shared with it, so what is recorded belongs to THIS
  ///   connection and cannot be overwritten by another one.
  RememberingVerifier(QString pin, EMailTlsSetup::SeenCertificatePtr seen)
      : pin_(std::move(pin)), seen_(std::move(seen)) {}

  void verify(const vmime::shared_ptr<cert::certificateChain>& chain,
              const vmime::string& hostname) override {
    remember(chain, hostname);
    apply_pin(chain);
    cert::defaultCertificateVerifier::verify(chain, hostname);
  }

 private:
  QString pin_;

  /// A pin names the certificate the SERVER PRESENTS, which is the whole point
  /// of pinning: the case it exists for is a self-signed or privately-issued
  /// certificate, and such a certificate is by definition not in the system
  /// root store. Matching the pin against the roots -- as this once did --
  /// could therefore only ever match a certificate that already verified, so
  /// pinning silently did nothing at all.
  ///
  /// Trust is still expressed by handing the leaf to the base class as a
  /// trusted certificate rather than by skipping any check, so the base
  /// class's ordering continues to hold: expiry is judged BEFORE the trusted
  /// comparison and the hostname AFTER it. A pinned certificate that has
  /// expired, is not yet valid, or names another host still fails. That is
  /// what keeps a pin from degrading into "ignore TLS errors".
  void apply_pin(const vmime::shared_ptr<cert::certificateChain>& chain) {
    if (pin_.isEmpty() || !chain || chain->getCount() == 0) return;

    auto leaf = vmime::dynamicCast<cert::X509Certificate>(chain->getAt(0));
    if (!leaf || FingerprintOf(leaf) != pin_) return;

    std::vector<vmime::shared_ptr<cert::X509Certificate>> trusted;
    trusted.push_back(leaf);
    setX509TrustedCerts(trusted);
  }

  void remember(const vmime::shared_ptr<cert::certificateChain>& chain,
                const vmime::string& hostname) {
    if (!chain || chain->getCount() == 0) return;

    auto leaf = vmime::dynamicCast<cert::X509Certificate>(chain->getAt(0));
    if (!leaf) return;

    // The issuer alone is a poor basis for a decision: for an interception
    // certificate it is whatever the attacker's CA calls itself, and it says
    // nothing about WHICH host the certificate claims to be. vmime exposes no
    // subject accessor, but it can answer the question that actually matters
    // -- whether this certificate is valid for the host being connected to --
    // so that is recorded alongside, alone with the validity window.
    const auto issuer =
        QString::fromStdString(leaf->getIssuerString()).trimmed();
    const auto host = QString::fromStdString(hostname);

    bool names_host = false;
    try {
      names_host = leaf->verifyHostName(hostname);
    } catch (...) {
      names_host = false;
    }

    const auto format = [](const vmime::datetime& d) {
      return QDate(d.getYear(), d.getMonth(), d.getDay()).toString(Qt::ISODate);
    };

    QStringList lines;
    lines << QObject::tr("Issued by: %1").arg(issuer);
    lines << (names_host ? QObject::tr("Valid for: %1").arg(host)
                         : QObject::tr("NOT valid for %1: it names a different "
                                       "host")
                               .arg(host));
    lines << QObject::tr("Valid from %1 to %2")
                 .arg(format(leaf->getActivationDate()))
                 .arg(format(leaf->getExpirationDate()));

    if (!seen_) return;
    seen_->fingerprint = FingerprintOf(leaf);
    seen_->summary = lines.join('\n');
  }

  EMailTlsSetup::SeenCertificatePtr seen_;
};

}  // namespace

namespace EMailTlsSetup {

auto ProtocolName(bool imap, MailTlsMode mode) -> QString {
  // Implicit TLS is a different vmime protocol; STARTTLS is the plain protocol
  // plus a property. kNONE is the plain protocol with that property off, and
  // is only ever reached for a loopback host.
  if (mode == MailTlsMode::kIMPLICIT) return imap ? "imaps" : "smtps";
  return imap ? "imap" : "smtp";
}

auto Apply(const vmime::shared_ptr<vmime::net::session>& session,
           const vmime::shared_ptr<vmime::net::service>& service,
           const QString& prefix, const MailTransportConfig& config, bool imap)
    -> SeenCertificatePtr {
  auto seen = std::make_shared<SeenCertificate>();
  if (!session || !service) return seen;

  auto& properties = session->getProperties();

  // The second load-bearing line of this file, and for a long time a missing
  // one. vmime treats SMTP authentication as OPTIONAL and defaults
  // "options.need-authentication" to false (SMTPServiceInfos.cpp:58). With it
  // false, SMTPConnection::connect() sets m_authenticated = true and returns
  // WITHOUT SENDING ANY AUTH COMMAND at all -- so every password verified,
  // every password was accepted, and mail was submitted unauthenticated.
  //
  // IMAP has no such property and authenticates unconditionally, which is why
  // this is asked for only on the SMTP side.
  if (!imap && !config.username.isEmpty()) {
    properties.setProperty(
        QString("%1.options.need-authentication").arg(prefix).toStdString(),
        true);
  }

  const auto tls_key = QString("%1.connection.tls").arg(prefix).toStdString();
  const auto required_key =
      QString("%1.connection.tls.required").arg(prefix).toStdString();

  if (config.tls == MailTlsMode::kNONE) {
    // Only ever reached for loopback; the caller has already refused this for
    // any other host.
    properties.setProperty(tls_key, false);
    properties.setProperty(required_key, false);
    return seen;
  }

  properties.setProperty(tls_key, true);

  // The load-bearing line of this whole file. vmime defaults this to false,
  // and with it false a server that refuses STARTTLS gets a cleartext session
  // that then carries the password. Setting it makes that failure fatal.
  properties.setProperty(required_key, true);

  auto verifier =
      vmime::make_shared<RememberingVerifier>(config.pinned_cert_sha256, seen);
  verifier->setX509RootCAs(RootCertificates());

  service->setCertificateVerifier(verifier);
  return seen;
}

auto IsSecured(const vmime::shared_ptr<vmime::net::service>& service) -> bool {
  if (!service) return false;

  auto infos = service->getConnectionInfos();
  if (!infos) return false;

  // vmime says "this link is encrypted" by the concrete type of the infos
  // object rather than with a flag, so this is the only honest way to ask.
  return vmime::dynamicCast<const vmime::net::tls::TLSSecuredConnectionInfos>(
             infos) != nullptr;
}

}  // namespace EMailTlsSetup