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 ImportKeyDialog: the guarded key-file read, the blocking
6 : * `gpg --import` call and the status-line parser for the imported key id.
7 : */
8 : #include "importkeydialog.h"
9 : #include "plainmessagebox.h"
10 : #include "util.h"
11 :
12 : #include "executor.h"
13 : #include "ui_importkeydialog.h"
14 :
15 : #include <QApplication>
16 : #include <QClipboard>
17 : #include <QFile>
18 : #include <QFileDialog>
19 : #include <QFileInfo>
20 : #include <QMessageBox>
21 : #include <QPlainTextEdit>
22 : #include <QRegularExpression>
23 : #include <QString>
24 :
25 : // Locale-independent: gpg's machine-readable status output via --status-fd 1.
26 : // See doc/DETAILS in the GnuPG source for IMPORT_OK / IMPORTED grammar.
27 : // Doxygen reads `Type name(args);` as a function, so these three constants
28 : // are hidden from it and described here instead.
29 : /// @cond
30 : // `[GNUPG:] IMPORT_OK <reason> <fingerprint>`: captures the full fingerprint,
31 : // the preferred identifier.
32 : static const QRegularExpression
33 : IMPORT_OK_RE(QStringLiteral(R"(\[GNUPG:\] IMPORT_OK \d+ ([0-9A-Fa-f]+))"));
34 : // `[GNUPG:] IMPORTED <keyid> <userid>`: captures the 16-hex long key id, tried
35 : // after IMPORT_OK_RE.
36 : static const QRegularExpression
37 : IMPORTED_RE(QStringLiteral(R"(\[GNUPG:\] IMPORTED ([0-9A-Fa-f]{16}))"));
38 : // Fallback for the human-readable (English-locale) `gpg: key <id>: ...
39 : // imported` line, last resort when no status line was seen.
40 : static const QRegularExpression KEY_IMPORTED_FALLBACK(
41 : QStringLiteral(R"(gpg: key ([0-9A-Fa-f]{40}|[0-9A-Fa-f]{16}):.*imported)"));
42 : /// @endcond
43 :
44 18 : ImportKeyDialog::ImportKeyDialog(const QString &gpgExe, QWidget *parent)
45 18 : : QDialog(parent), ui(new Ui::ImportKeyDialog), m_gpgExe(gpgExe) {
46 18 : ui->setupUi(this);
47 18 : ui->importButton->setEnabled(false);
48 18 : }
49 :
50 36 : ImportKeyDialog::~ImportKeyDialog() = default;
51 :
52 7 : auto ImportKeyDialog::importedKeyId() const -> QString {
53 7 : return m_importedKeyId;
54 : }
55 :
56 5 : void ImportKeyDialog::on_fileButton_clicked() {
57 : const QString fileName = QFileDialog::getOpenFileName(
58 10 : this, tr("Import GPG key"), QString(),
59 20 : tr("ASCII-armored GPG key") + " (*.asc);;" + tr("All files") + " (*)");
60 :
61 5 : if (fileName.isEmpty()) {
62 : return;
63 : }
64 :
65 : // A key file, so a regular one of sensible size: a FIFO would block the
66 : // GUI and /dev/zero (or a sparse file) would be read without end.
67 : constexpr qint64 kMaxKeyFileBytes = 16LL * 1024 * 1024;
68 : // A link is followed, deliberately: the user picked this file, unlike a
69 : // name found in the store. Its target is then opened as the regular file
70 : // it is at the moment of the open, without blocking: a FIFO swapped in
71 : // after the pick would otherwise hang the GUI waiting for a writer.
72 4 : QFile file;
73 4 : const QString target = QFileInfo(fileName).canonicalFilePath();
74 4 : if (target.isEmpty() || !Util::openRegularFile(target, file)) {
75 4 : PlainMessageBox::warning(this, tr("Import key"),
76 4 : tr("Could not open file: %1").arg(fileName));
77 2 : return;
78 : }
79 :
80 : // The size first, so a large file is not read at all; the read limit
81 : // still guards one that grows meanwhile.
82 2 : const bool tooLarge = file.size() > kMaxKeyFileBytes;
83 : const QByteArray bytes =
84 2 : tooLarge ? QByteArray() : file.read(kMaxKeyFileBytes + 1);
85 2 : file.close();
86 2 : if (tooLarge || bytes.size() > kMaxKeyFileBytes) {
87 0 : PlainMessageBox::warning(
88 0 : this, tr("Import key"),
89 0 : tr("%1 is larger than a key file can be.").arg(fileName));
90 0 : return;
91 : }
92 :
93 : // Only ASCII-armored content is shown in the text edit; binary keyrings
94 : // would lose bytes through UTF-8 conversion. Reject anything that doesn't
95 : // start with the PGP armor header.
96 2 : if (!bytes.trimmed().startsWith("-----BEGIN PGP")) {
97 : // Message body is rich text (uses <code>/<b>); escape the path so any
98 : // characters in it cannot reach the HTML subset Qt renders.
99 2 : QMessageBox::warning(
100 1 : this, tr("Import key"),
101 1 : tr("%1 does not look like an ASCII-armored GPG key. Convert it with "
102 : "<code>gpg --armor --export</code> first, or paste the armored "
103 : "block via <b>From clipboard</b>.")
104 2 : .arg(fileName.toHtmlEscaped()));
105 1 : return;
106 : }
107 :
108 2 : ui->inputTextEdit->setPlainText(QString::fromUtf8(bytes));
109 4 : }
110 :
111 2 : void ImportKeyDialog::on_pasteButton_clicked() {
112 2 : const QClipboard *clipboard = QApplication::clipboard();
113 2 : const QString text = clipboard->text();
114 2 : if (text.isEmpty()) {
115 : // Don't wipe whatever the user already typed/loaded for an empty
116 : // clipboard.
117 : return;
118 : }
119 1 : ui->inputTextEdit->setPlainText(text);
120 : }
121 :
122 6 : void ImportKeyDialog::on_importButton_clicked() {
123 12 : const QString input = ui->inputTextEdit->toPlainText().trimmed();
124 6 : if (input.isEmpty()) {
125 : return;
126 : }
127 :
128 5 : if (importFromString(input)) {
129 3 : accept();
130 : }
131 : }
132 :
133 13 : void ImportKeyDialog::on_inputTextEdit_textChanged() {
134 39 : const bool hasInput = !ui->inputTextEdit->toPlainText().trimmed().isEmpty();
135 13 : ui->importButton->setEnabled(hasInput);
136 13 : }
137 :
138 5 : bool ImportKeyDialog::importFromString(const QString &input) {
139 5 : QString gpgExe = m_gpgExe.isEmpty() ? QStringLiteral("gpg") : m_gpgExe;
140 55 : QStringList args = {"--status-fd", "1", "--import", "--batch", "--yes"};
141 :
142 5 : QString stdOut;
143 5 : QString stdErr;
144 :
145 5 : int result = Executor::executeBlocking(gpgExe, args, input, &stdOut, &stdErr);
146 :
147 5 : if (result != 0) {
148 2 : showError(tr("GPG import failed:\n%1").arg(stdErr));
149 1 : return false;
150 : }
151 :
152 4 : QString keyId = parseGpgImportOutput(stdOut);
153 4 : if (keyId.isEmpty()) {
154 4 : keyId = parseGpgImportOutput(stdErr);
155 : }
156 :
157 4 : if (keyId.isEmpty()) {
158 1 : showError(tr("Could not parse imported key id from GPG output."));
159 1 : return false;
160 : }
161 :
162 3 : m_importedKeyId = keyId;
163 3 : showSuccess(keyId);
164 : return true;
165 : }
166 :
167 32 : auto ImportKeyDialog::parseGpgImportOutput(const QString &output) -> QString {
168 32 : const QStringList lines = output.split('\n', Qt::SkipEmptyParts);
169 : // The order here is the priority order: prefer the locale-independent
170 : // status lines over the English human-readable line, and within the
171 : // status lines prefer IMPORT_OK (full fingerprint) over IMPORTED
172 : // (long key id). Each regex is scanned across every line before we
173 : // fall through to the next regex; the per-line ordering of gpg's
174 : // output (which emits IMPORTED before IMPORT_OK) must not pick the
175 : // weaker identifier.
176 54 : for (const QString &line : lines) {
177 35 : const QRegularExpressionMatch match = IMPORT_OK_RE.match(line);
178 35 : if (match.hasMatch()) {
179 13 : return match.captured(1);
180 : }
181 35 : }
182 35 : for (const QString &line : lines) {
183 18 : const QRegularExpressionMatch match = IMPORTED_RE.match(line);
184 18 : if (match.hasMatch()) {
185 2 : return match.captured(1);
186 : }
187 18 : }
188 27 : for (const QString &line : lines) {
189 16 : const QRegularExpressionMatch match = KEY_IMPORTED_FALLBACK.match(line);
190 16 : if (match.hasMatch()) {
191 6 : return match.captured(1);
192 : }
193 16 : }
194 11 : return {};
195 : }
196 :
197 2 : void ImportKeyDialog::showError(const QString &message) {
198 : // gpg's stderr quotes user IDs ("Name <a@b>"): plain, so they show as
199 : // written and cannot be markup.
200 2 : PlainMessageBox::warning(this, tr("Import key"), message);
201 2 : }
202 :
203 3 : void ImportKeyDialog::showSuccess(const QString &keyId) {
204 9 : QMessageBox::information(this, tr("Import key"),
205 3 : tr("Public key %1 imported. Check its fingerprint "
206 : "with its owner before encrypting to it: "
207 : "importing a key says nothing about who holds "
208 : "it.")
209 6 : .arg(keyId));
210 3 : }
|