QtPass Developer documentation
QtPass test coverage
Current view: top level - src/gpgidsigner.cpp (source / functions) Coverage Total Hit
Test: QtPass Lines: 92.0 % 113 104
Test Date: 2026-11-14 13:19:18 Functions: 100.0 % 8 8

            Line data    Source code
       1              : // SPDX-FileCopyrightText: 2026 Anne Jan Brouwer
       2              : // SPDX-License-Identifier: GPL-3.0-or-later
       3              : /**
       4              :  * @file
       5              :  * @brief Implements GpgIdSigner on top of gpg's `--status-fd` output.
       6              :  */
       7              : #include "gpgidsigner.h"
       8              : 
       9              : #include "executor.h"
      10              : #include "util.h"
      11              : #include <QCoreApplication>
      12              : #include <QFile>
      13              : #include <QRegularExpression>
      14              : #include <QTemporaryDir>
      15              : #include <utility>
      16              : 
      17              : #include "qtpasslogging.h"
      18              : 
      19          459 : GpgIdSigner::GpgIdSigner(QString gpgExecutable, QStringList signingKeys,
      20          459 :                          Exec exec)
      21              :     : m_gpg(std::move(gpgExecutable)), m_keys(std::move(signingKeys)),
      22              :       m_exec(std::move(exec)) {
      23          459 :   if (!m_exec) {
      24           45 :     m_exec = [](const QString &app, const QStringList &args,
      25              :                 const QString &input, QString *out, QString *err) {
      26            9 :       return Executor::executeBlocking(app, args, input, out, err);
      27           45 :     };
      28              :   }
      29          459 : }
      30              : 
      31          444 : auto GpgIdSigner::keysFromSetting(const QString &passSigningKey)
      32              :     -> QStringList {
      33          444 :   QStringList keys = passSigningKey.split(QLatin1Char(' '), Qt::SkipEmptyParts);
      34              :   // gpg prints fingerprints in upper case in its status lines; accept them
      35              :   // typed either way.
      36         1051 :   for (QString &key : keys) {
      37          163 :     key = key.toUpper();
      38              :   }
      39          444 :   return keys;
      40              : }
      41              : 
      42          185 : auto GpgIdSigner::run(const QStringList &args, QString *out, QString *err,
      43              :                       const QString &input) const -> int {
      44          185 :   return m_exec(m_gpg, args, input, out, err);
      45              : }
      46              : 
      47           30 : auto GpgIdSigner::haveSecretKey() const -> bool {
      48           30 :   if (!enabled()) {
      49              :     return false;
      50              :   }
      51              :   // Only the first key signs (see sign()), so only its secret key matters:
      52              :   // a usable second key would let Init start and fail at the signature.
      53              :   const QString &key = m_keys.first();
      54           29 :   QString out;
      55          174 :   const int rc = run({QStringLiteral("--status-fd=1"),
      56           29 :                       QStringLiteral("--list-secret-keys"), key},
      57              :                      &out);
      58           29 :   if (rc != 0) {
      59            2 :     qCDebug(lcQtPass) << "GPG list-secret-keys failed with code:" << rc;
      60            2 :     return false;
      61              :   }
      62           54 :   return out.contains(QStringLiteral("[GNUPG:] KEY_CONSIDERED ") + key);
      63              : }
      64              : 
      65           39 : auto GpgIdSigner::sign(const QString &gpgIdFile, const QByteArray &contents,
      66              :                        QString *error) const -> bool {
      67           39 :   if (!enabled()) {
      68              :     return true;
      69              :   }
      70           38 :   if (m_keys.size() > 1) {
      71            4 :     qCDebug(lcQtPass)
      72            0 :         << "Multiple signing keys configured; using only the first key:"
      73            0 :         << m_keys.first();
      74              :   }
      75              :   // The executor feeds stdin as UTF-8 text (see verify()).
      76              :   const QString text = QString::fromUtf8(contents);
      77           38 :   if (text.toUtf8() != contents) {
      78            1 :     if (error)
      79            2 :       *error = QCoreApplication::translate(
      80              :           "GpgIdSigner", "the recipient list is not valid UTF-8");
      81            1 :     return false;
      82              :   }
      83           37 :   QTemporaryDir scratch;
      84           37 :   if (!scratch.isValid()) {
      85            0 :     if (error)
      86            0 :       *error = scratch.errorString();
      87            0 :     return false;
      88              :   }
      89           74 :   const QString output = scratch.filePath(QStringLiteral("sig"));
      90              :   // --local-user, not --default-key: without that secret key gpg would
      91              :   // print "all values passed to '--default-key' ignored", sign with
      92              :   // whatever secret key it has and exit 0.
      93          333 :   const int rc = run(
      94           37 :       {QStringLiteral("--local-user"), m_keys.first(), QStringLiteral("--yes"),
      95           37 :        QStringLiteral("--detach-sign"), QStringLiteral("--output"),
      96           37 :        Executor::translatePathForWsl(output, m_gpg), QStringLiteral("-")},
      97              :       nullptr, error, text);
      98           37 :   if (rc != 0) {
      99            6 :     qCDebug(lcQtPass) << "GPG signing failed with code:" << rc;
     100            6 :     return false;
     101              :   }
     102           31 :   QFile signature(output);
     103           31 :   if (!signature.open(QIODevice::ReadOnly) || signature.size() <= 0) {
     104            2 :     if (error)
     105              :       *error =
     106            4 :           QCoreApplication::translate("GpgIdSigner", "gpg wrote no signature");
     107            2 :     return false;
     108              :   }
     109           29 :   const QByteArray bytes = signature.readAll();
     110           29 :   signature.close();
     111              :   // Checked before it is placed: a signature by any key but the configured
     112              :   // ones (an older gpg, a wrapper, a smartcard picking its own) is not
     113              :   // written, and the one there, if any, stays.
     114           29 :   if (!verify(contents, output)) {
     115            4 :     if (error)
     116            8 :       *error = QCoreApplication::translate(
     117              :           "GpgIdSigner",
     118              :           "the new signature does not verify as one of the signing keys");
     119            4 :     return false;
     120              :   }
     121           50 :   return Util::writeFileReplacing(gpgIdFile + QStringLiteral(".sig"), bytes,
     122              :                                   true, error);
     123          105 : }
     124              : 
     125          126 : auto GpgIdSigner::validSigFingerprints(const QString &statusOutput)
     126              :     -> QStringList {
     127              :   static const QRegularExpression re(
     128              :       // 40 hex for v4 keys, 64 for v5/v6 (gpg 2.5+), either position.
     129           12 :       R"(^\[GNUPG:\] VALIDSIG ([A-F0-9]{40}(?:[A-F0-9]{24})?) .* )"
     130              :       R"(([A-F0-9]{40}(?:[A-F0-9]{24})?)\r?$)",
     131          144 :       QRegularExpression::MultilineOption);
     132              :   // gpg reports a signature by a revoked key as REVKEYSIG plus KEYREVOKED,
     133              :   // and still prints VALIDSIG and exits 0. A signing key revoked because it
     134              :   // was compromised must not vouch for a recipient list, so a signature
     135              :   // whose block (from one NEWSIG to the next) says so does not count. One
     136              :   // by an expired key does, as gpg calls it a good signature: expiry is not
     137              :   // compromise, and pass accepts it too.
     138              :   static const QRegularExpression revoked(
     139            6 :       QStringLiteral(R"(^\[GNUPG:\] (?:REVKEYSIG|KEYREVOKED)\b)"),
     140          138 :       QRegularExpression::MultilineOption);
     141              :   static const QRegularExpression newSig(
     142            6 :       QStringLiteral(R"(^\[GNUPG:\] NEWSIG\b)"),
     143          138 :       QRegularExpression::MultilineOption);
     144          126 :   const QRegularExpressionMatch m = re.match(statusOutput);
     145          126 :   if (!m.hasMatch()) {
     146            5 :     return {};
     147              :   }
     148              :   const qsizetype from =
     149          121 :       statusOutput.lastIndexOf(newSig, m.capturedStart()) < 0
     150          121 :           ? 0
     151           26 :           : statusOutput.lastIndexOf(newSig, m.capturedStart());
     152          121 :   const qsizetype to = statusOutput.indexOf(newSig, m.capturedEnd());
     153          121 :   const QString block = statusOutput.mid(from, to < 0 ? -1 : to - from);
     154          121 :   if (revoked.match(block).hasMatch()) {
     155            3 :     return {};
     156              :   }
     157          354 :   return {m.captured(1), m.captured(2)};
     158          244 : }
     159              : 
     160          139 : auto GpgIdSigner::verify(const QByteArray &contents,
     161              :                          const QString &signatureFile) const -> bool {
     162          139 :   if (!enabled()) {
     163              :     return true;
     164              :   }
     165              :   // The executor feeds stdin as UTF-8 text. A .gpg-id is one: key IDs,
     166              :   // fingerprints, e-mail addresses, comments. Bytes that would not survive
     167              :   // the round trip cannot be verified faithfully, so they are not verified
     168              :   // at all rather than as something else.
     169              :   const QString text = QString::fromUtf8(contents);
     170          138 :   if (text.toUtf8() != contents) {
     171            1 :     qCDebug(lcQtPass) << "Refusing to verify" << signatureFile
     172            0 :                       << ": the signed file is not valid UTF-8";
     173            1 :     return false;
     174              :   }
     175              :   // Same for the signature: a valid pair replayed from elsewhere through two
     176              :   // links is not this folder's list.
     177          137 :   if (Util::isLinkedFolder(signatureFile)) {
     178            1 :     qCDebug(lcQtPass) << "Refusing to verify" << signatureFile
     179            0 :                       << ": the signature file is a link";
     180            1 :     return false;
     181              :   }
     182              :   // A regular file of bounded size, as the list itself is read: a FIFO
     183              :   // planted under the name would block gpg, and the GUI thread waiting for
     184              :   // it, and a sparse file of gigabytes would be read whole.
     185              :   {
     186          136 :     QFile probe;
     187          256 :     if (!Util::openRegularFile(signatureFile, probe) ||
     188          120 :         probe.size() > kMaxSignatureBytes) {
     189           17 :       qCDebug(lcQtPass) << "Refusing to verify" << signatureFile
     190            0 :                         << ": not a regular file of at most"
     191            0 :                         << kMaxSignatureBytes << "bytes";
     192              :       return false;
     193              :     }
     194          136 :   }
     195          119 :   QString out;
     196          119 :   const QString sig = Executor::translatePathForWsl(signatureFile, m_gpg);
     197              :   // "-" makes gpg read the signed data from stdin: the bytes we hold.
     198              :   const int rc =
     199          952 :       run({QStringLiteral("--verify"), QStringLiteral("--status-fd=1"),
     200          119 :            QStringLiteral("--"), sig, QStringLiteral("-")},
     201              :           &out, nullptr, text);
     202          119 :   if (rc != 0) {
     203            3 :     qCDebug(lcQtPass) << "GPG verify failed with code:" << rc;
     204            3 :     return false;
     205              :   }
     206          116 :   const QStringList fingerprints = validSigFingerprints(out);
     207          124 :   for (const QString &key : m_keys) {
     208          117 :     if (fingerprints.contains(key)) {
     209              :       return true;
     210              :     }
     211              :   }
     212              :   return false;
     213              : }
     214              : 
     215          104 : auto GpgIdSigner::verifyFile(const QString &gpgIdFile,
     216              :                              QByteArray *contents) const -> bool {
     217              :   // A link under the name is not the store's .gpg-id, whatever it points
     218              :   // to and however well that is signed.
     219          104 :   if (Util::isLinkedFolder(gpgIdFile)) {
     220            1 :     contents->clear();
     221            1 :     return false;
     222              :   }
     223              :   // Read as the regular file it is and bounded: a FIFO would block, a
     224              :   // sparse file of gigabytes be read whole, before any signature is checked.
     225              :   std::optional<QByteArray> bytes =
     226          103 :       Util::readRegularFile(gpgIdFile, Util::kMaxGpgIdBytes);
     227          103 :   if (!bytes) {
     228            7 :     contents->clear();
     229              :     return false;
     230              :   }
     231              :   *contents = *std::move(bytes);
     232          192 :   return verify(*contents, gpgIdFile + QStringLiteral(".sig"));
     233              : }
        

Generated by: LCOV version 2.0-1