QtPass 2.0.0
Developer documentation
Loading...
Searching...
No Matches
gpgidsigner.cpp
Go to the documentation of this file.
1// SPDX-FileCopyrightText: 2026 Anne Jan Brouwer
2// SPDX-License-Identifier: GPL-3.0-or-later
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
19GpgIdSigner::GpgIdSigner(QString gpgExecutable, QStringList signingKeys,
20 Exec exec)
21 : m_gpg(std::move(gpgExecutable)), m_keys(std::move(signingKeys)),
22 m_exec(std::move(exec)) {
23 if (!m_exec) {
24 m_exec = [](const QString &app, const QStringList &args,
25 const QString &input, QString *out, QString *err) {
26 return Executor::executeBlocking(app, args, input, out, err);
27 };
28 }
29}
30
31auto GpgIdSigner::keysFromSetting(const QString &passSigningKey)
32 -> QStringList {
33 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 for (QString &key : keys) {
37 key = key.toUpper();
38 }
39 return keys;
40}
41
42auto GpgIdSigner::run(const QStringList &args, QString *out, QString *err,
43 const QString &input) const -> int {
44 return m_exec(m_gpg, args, input, out, err);
45}
46
47auto GpgIdSigner::haveSecretKey() const -> bool {
48 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 QString out;
55 const int rc = run({QStringLiteral("--status-fd=1"),
56 QStringLiteral("--list-secret-keys"), key},
57 &out);
58 if (rc != 0) {
59 qCDebug(lcQtPass) << "GPG list-secret-keys failed with code:" << rc;
60 return false;
61 }
62 return out.contains(QStringLiteral("[GNUPG:] KEY_CONSIDERED ") + key);
63}
64
65auto GpgIdSigner::sign(const QString &gpgIdFile, const QByteArray &contents,
66 QString *error) const -> bool {
67 if (!enabled()) {
68 return true;
69 }
70 if (m_keys.size() > 1) {
71 qCDebug(lcQtPass)
72 << "Multiple signing keys configured; using only the first key:"
73 << m_keys.first();
74 }
75 // The executor feeds stdin as UTF-8 text (see verify()).
76 const QString text = QString::fromUtf8(contents);
77 if (text.toUtf8() != contents) {
78 if (error)
79 *error = QCoreApplication::translate(
80 "GpgIdSigner", "the recipient list is not valid UTF-8");
81 return false;
82 }
83 QTemporaryDir scratch;
84 if (!scratch.isValid()) {
85 if (error)
86 *error = scratch.errorString();
87 return false;
88 }
89 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 const int rc = run(
94 {QStringLiteral("--local-user"), m_keys.first(), QStringLiteral("--yes"),
95 QStringLiteral("--detach-sign"), QStringLiteral("--output"),
96 Executor::translatePathForWsl(output, m_gpg), QStringLiteral("-")},
97 nullptr, error, text);
98 if (rc != 0) {
99 qCDebug(lcQtPass) << "GPG signing failed with code:" << rc;
100 return false;
101 }
102 QFile signature(output);
103 if (!signature.open(QIODevice::ReadOnly) || signature.size() <= 0) {
104 if (error)
105 *error =
106 QCoreApplication::translate("GpgIdSigner", "gpg wrote no signature");
107 return false;
108 }
109 const QByteArray bytes = signature.readAll();
110 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 if (!verify(contents, output)) {
115 if (error)
116 *error = QCoreApplication::translate(
117 "GpgIdSigner",
118 "the new signature does not verify as one of the signing keys");
119 return false;
120 }
121 return Util::writeFileReplacing(gpgIdFile + QStringLiteral(".sig"), bytes,
122 true, error);
123}
124
125auto 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 R"(^\‍[GNUPG:\‍] VALIDSIG ([A-F0-9]{40}(?:[A-F0-9]{24})?) .* )"
130 R"(([A-F0-9]{40}(?:[A-F0-9]{24})?)\r?$)",
131 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 QStringLiteral(R"(^\‍[GNUPG:\‍] (?:REVKEYSIG|KEYREVOKED)\b)"),
140 QRegularExpression::MultilineOption);
141 static const QRegularExpression newSig(
142 QStringLiteral(R"(^\‍[GNUPG:\‍] NEWSIG\b)"),
143 QRegularExpression::MultilineOption);
144 const QRegularExpressionMatch m = re.match(statusOutput);
145 if (!m.hasMatch()) {
146 return {};
147 }
148 const qsizetype from =
149 statusOutput.lastIndexOf(newSig, m.capturedStart()) < 0
150 ? 0
151 : statusOutput.lastIndexOf(newSig, m.capturedStart());
152 const qsizetype to = statusOutput.indexOf(newSig, m.capturedEnd());
153 const QString block = statusOutput.mid(from, to < 0 ? -1 : to - from);
154 if (revoked.match(block).hasMatch()) {
155 return {};
156 }
157 return {m.captured(1), m.captured(2)};
158}
159
160auto GpgIdSigner::verify(const QByteArray &contents,
161 const QString &signatureFile) const -> bool {
162 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 if (text.toUtf8() != contents) {
171 qCDebug(lcQtPass) << "Refusing to verify" << signatureFile
172 << ": the signed file is not valid UTF-8";
173 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 if (Util::isLinkedFolder(signatureFile)) {
178 qCDebug(lcQtPass) << "Refusing to verify" << signatureFile
179 << ": the signature file is a link";
180 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 QFile probe;
187 if (!Util::openRegularFile(signatureFile, probe) ||
188 probe.size() > kMaxSignatureBytes) {
189 qCDebug(lcQtPass) << "Refusing to verify" << signatureFile
190 << ": not a regular file of at most"
191 << kMaxSignatureBytes << "bytes";
192 return false;
193 }
194 }
195 QString out;
196 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 run({QStringLiteral("--verify"), QStringLiteral("--status-fd=1"),
200 QStringLiteral("--"), sig, QStringLiteral("-")},
201 &out, nullptr, text);
202 if (rc != 0) {
203 qCDebug(lcQtPass) << "GPG verify failed with code:" << rc;
204 return false;
205 }
206 const QStringList fingerprints = validSigFingerprints(out);
207 for (const QString &key : m_keys) {
208 if (fingerprints.contains(key)) {
209 return true;
210 }
211 }
212 return false;
213}
214
215auto 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 if (Util::isLinkedFolder(gpgIdFile)) {
220 contents->clear();
221 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 =
227 if (!bytes) {
228 contents->clear();
229 return false;
230 }
231 *contents = *std::move(bytes);
232 return verify(*contents, gpgIdFile + QStringLiteral(".sig"));
233}
static auto executeBlocking(const QString &app, const QStringList &args, const QString &input=QString(), QString *process_out=nullptr, QString *process_err=nullptr) -> int
Run a command synchronously and return its exit code.
Definition executor.cpp:422
static auto translatePathForWsl(const QString &path, const QString &exe) -> QString
Translate a native path for a WSL-routed executable.
Definition executor.cpp:177
static auto validSigFingerprints(const QString &statusOutput) -> QStringList
Pick the fingerprints out of gpg's --status-fd VALIDSIG line.
Exec m_exec
How gpg is run; replaced by a fake in the unit tests.
auto enabled() const -> bool
Whether any signing key is configured at all.
Definition gpgidsigner.h:72
auto run(const QStringList &args, QString *out=nullptr, QString *err=nullptr, const QString &input=QString()) const -> int
Run gpg with args through m_exec.
auto sign(const QString &gpgIdFile, const QByteArray &contents, QString *error=nullptr) const -> bool
Write <file>.sig, a detached signature by the first key over contents, the bytes the caller just wrot...
GpgIdSigner(QString gpgExecutable, QStringList signingKeys, Exec exec=Exec())
Create a signer.
std::function< int(const QString &app, const QStringList &args, const QString &input, QString *out, QString *err)> Exec
Blocking process runner: Executor::executeBlocking()'s shape.
Definition gpgidsigner.h:41
QStringList m_keys
The configured signing keys. The first one signs; a signature by any of them verifies.
auto keys() const -> const QStringList &
The keys this signer was given.
Definition gpgidsigner.h:78
auto verifyFile(const QString &gpgIdFile, QByteArray *contents) const -> bool
Read gpgIdFile and verify it against <file>.sig.
static constexpr qint64 kMaxSignatureBytes
Most bytes of a .gpg-id.sig verify() hands to gpg.
Definition gpgidsigner.h:35
QString m_gpg
The gpg executable, as configured.
static auto keysFromSetting(const QString &passSigningKey) -> QStringList
Split the settings value into keys, upper-cased the way gpg prints fingerprints.
auto verify(const QByteArray &contents, const QString &signatureFile) const -> bool
Check a detached signature against the given bytes: gpg must report a VALIDSIG whose key or primary-k...
auto haveSecretKey() const -> bool
Whether gpg has the secret key that sign() will use, i.e. the first configured key.
static auto writeFileReplacing(const QString &path, const QByteArray &bytes, bool replace, QString *error=nullptr) -> bool
Write bytes as the file path through stageFileReplacing().
Definition util.cpp:1165
static constexpr qint64 kMaxGpgIdBytes
Most bytes of a .gpg-id QtPass reads.
Definition util.h:387
static auto readRegularFile(const QString &path, qint64 maxBytes) -> std::optional< QByteArray >
Read path whole, as the regular file it is (openRegularFile()), when it holds at most maxBytes.
Definition util.cpp:973
static auto isLinkedFolder(const QString &path) -> bool
Whether path names a symbolic link or NTFS junction, with or without a trailing separator (which woul...
Definition util.cpp:761
static auto openRegularFile(const QString &path, QFile &file) -> bool
Open path for reading as the regular file it is, not as what a link under that name points to: the ob...
Definition util.cpp:913
Executor, the FIFO queue every gpg, git and pass child process of QtPass goes through,...
GpgIdSigner, which signs a store's .gpg-id and verifies an existing .gpg-id.sig against the configure...
The "qtpass" logging category.
Util: static helpers for the process environment, binary discovery, store paths, URL safety and the l...