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 : }
|