Line data Source code
1 : // SPDX-FileCopyrightText: 2014 Anne Jan Brouwer
2 : // SPDX-License-Identifier: GPL-3.0-or-later
3 : /**
4 : * @file
5 : * @brief Implements PasswordDisplayPanel; the value browser and
6 : * open-in-browser button are built by file-local helpers.
7 : */
8 : #include "passworddisplaypanel.h"
9 : #include "appsettings.h"
10 : #include "otpcodewidget.h"
11 : #include "qpushbuttonasqrcode.h"
12 : #include "qpushbuttonshowpassword.h"
13 : #include "qpushbuttonwithclipboard.h"
14 : #include "secretlineedit.h"
15 : #include "secrettextbrowser.h"
16 : #include "totp.h"
17 : #include "util.h"
18 :
19 : #include <QBoxLayout>
20 : #include <QDesktopServices>
21 : #include <QDrag>
22 : #include <QFrame>
23 : #include <QGridLayout>
24 : #include <QHBoxLayout>
25 : #include <QIcon>
26 : #include <QLabel>
27 : #include <QLayout>
28 : #include <QLineEdit>
29 : #include <QMouseEvent>
30 : #include <QPalette>
31 : #include <QPushButton>
32 : #include <QTextBrowser>
33 : #include <QUrl>
34 : #include <algorithm>
35 :
36 176 : PasswordDisplayPanel::PasswordDisplayPanel(QGridLayout *grid,
37 : QBoxLayout *container,
38 : QWidget *widgetParent,
39 176 : QObject *parent)
40 176 : : QObject(parent), m_grid(grid), m_container(container),
41 176 : m_widgetParent(widgetParent) {}
42 :
43 96 : void PasswordDisplayPanel::clear() {
44 174 : while (m_grid->count() > 0) {
45 78 : QLayoutItem *item = m_grid->takeAt(0);
46 78 : if (QWidget *widget = item->widget()) {
47 : // A drag out of a field value runs QDrag::exec() inside the value
48 : // widget's own mouse handler, and clear() can be reached from that
49 : // nested event loop (a drop on the search box, the panel autoclear
50 : // timer, a second "qtpass NAME"). Deleting the row then - even with
51 : // deleteLater(), which the drag's loop would deliver - frees it under
52 : // its own handler and crashes. A row that has a QDrag among its children
53 : // is hidden now and deleted once that QDrag is gone, which Qt does after
54 : // the drag ends; rows without a drag are deleted at once as before.
55 78 : const QList<QDrag *> drags = widget->findChildren<QDrag *>();
56 78 : if (drags.isEmpty()) {
57 77 : delete widget;
58 : } else {
59 1 : widget->hide();
60 1 : connect(drags.last(), &QObject::destroyed, widget,
61 3 : [widget]() { widget->deleteLater(); });
62 : }
63 : }
64 78 : delete item;
65 : }
66 96 : m_container->setSpacing(0);
67 96 : }
68 :
69 12 : auto PasswordDisplayPanel::currentOtpCode() const -> QString {
70 : // displayFields() renders at most one OTP row, so the first match is it.
71 36 : for (int i = 0; i < m_grid->count(); ++i) {
72 30 : QWidget *widget = m_grid->itemAt(i)->widget();
73 30 : if (widget == nullptr) {
74 0 : continue;
75 : }
76 30 : if (auto *otp = widget->findChild<OtpCodeWidget *>()) {
77 6 : return otp->code();
78 : }
79 : }
80 6 : return {};
81 : }
82 :
83 67 : void PasswordDisplayPanel::displayFields(const QString &password,
84 : const NamedValues &namedValues,
85 : const AppSettings &s,
86 : const QString &otpConfig) {
87 : // Rows are numbered as they are added rather than assuming the password
88 : // occupies row 0: an entry written by `pass otp insert` has no password row,
89 : // and starting at 1 regardless left an empty grid row above the OTP row.
90 : int position = 0;
91 : // Defence in depth: getPasswordForDisplay() already blanks a password line
92 : // that is an otpauth URI (`pass otp insert`); refuse one here too.
93 67 : if (!password.isEmpty() && !FileContent::isOtpUriValue(password)) {
94 : // The password is hidden in addField when needed.
95 130 : addField(position, QObject::tr("Password"), password, s);
96 : ++position;
97 : }
98 : bool otpRendered = false;
99 137 : for (const NamedValue &nv : namedValues) {
100 : // Keyed on the value too: a field called anything whose value is an otpauth
101 : // URI is still a shared secret, and isLineHidden() hides it from the text
102 : // browser, so suppressing only OTP/TOTP names leaked `2fa:` and friends.
103 125 : if (FileContent::isOtpFieldName(nv.name) ||
104 55 : FileContent::isOtpUriValue(nv.value)) {
105 : // Never render an OTP field verbatim: addField() would display the
106 : // shared secret and hand it to a copy button. Unconditional, so it stays
107 : // hidden when OTP support is off and otpConfig is empty.
108 16 : if (!otpRendered && !otpConfig.isEmpty()) {
109 12 : addOtpField(position, otpConfig, s);
110 12 : ++position;
111 : otpRendered = true;
112 : }
113 16 : continue;
114 : }
115 54 : addField(position, nv.name, nv.value, s);
116 54 : ++position;
117 : }
118 : // The configuration came from the entry body rather than from a field.
119 67 : if (!otpConfig.isEmpty() && !otpRendered) {
120 5 : addOtpField(position, otpConfig, s);
121 : }
122 133 : m_container->setSpacing(m_grid->count() == 0 ? 0 : 6);
123 67 : }
124 :
125 : namespace {
126 :
127 : // Scope every rule to the widget type so the transparent background does not
128 : // cascade into the field's standard context menu (a child QMenu), which would
129 : // otherwise render transparent too.
130 : const char kButtonStyle[] =
131 : "QPushButton { border-style: none; background: transparent; padding: 0; "
132 : "margin: 0; icon-size: 16px; color: inherit; }";
133 :
134 : // 26px matches the action-button visual height for consistent alignment.
135 : constexpr int kFieldHeight = 26;
136 :
137 : /// A read-only, one-line browser showing @p value. Only launchable http(s)
138 : /// URLs become anchors (the open-in-browser button's predicate):
139 : /// setOpenExternalLinks() would hand ssh://, ftp:// or user:pass@ URLs
140 : /// straight to the OS URL handler on click.
141 104 : auto valueBrowser(const QString &field, const QString &value,
142 : const QString &lineStyle) -> SecretTextBrowser * {
143 104 : auto *browser = new SecretTextBrowser();
144 104 : browser->setOpenExternalLinks(true);
145 104 : browser->setOpenLinks(true);
146 104 : browser->setMaximumHeight(kFieldHeight);
147 104 : browser->setMinimumHeight(kFieldHeight);
148 104 : browser->setSizePolicy(
149 : QSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum));
150 104 : browser->setObjectName(field);
151 104 : bool linked = false;
152 104 : const QString linkedText = Util::linkifyUrls(value, &linked);
153 104 : if (linked) {
154 : // Always HTML here: the text is escaped and carries anchor tags, so do
155 : // not leave the interpretation to setText()'s auto-detection.
156 18 : browser->setHtml(linkedText);
157 : } else {
158 : // Plain text: escaping plus setText() showed `a&b` as `a&b` unless
159 : // the value happened to contain a '<'.
160 86 : browser->setPlainText(value);
161 : }
162 104 : browser->setReadOnly(true);
163 104 : browser->setStyleSheet(lineStyle);
164 104 : browser->setContentsMargins(0, 0, 0, 0);
165 104 : return browser;
166 : }
167 :
168 : /// An "open in browser" button for @p url, already validated.
169 16 : auto urlButton(const QString &url, QWidget *parent) -> QPushButton * {
170 16 : auto *button = new QPushButton(parent);
171 32 : button->setIcon(QIcon::fromTheme(QStringLiteral("applications-internet"),
172 32 : QIcon(":/icons/open-url.svg")));
173 : // Escape for the tooltip only; the launched URL stays the validated value.
174 : // <qt> forces rich text: auto-detection would show `&` literally, and
175 : // plain text misreads a URL containing `<`. Rich text wraps, breaking
176 : // the URL at `/` and `?`; white-space:nowrap keeps it on one line (<nobr>
177 : // is not enough: Qt only makes its spaces non-breaking).
178 32 : button->setToolTip(
179 16 : QStringLiteral("<qt style=\"white-space:nowrap\">%1</qt>")
180 48 : .arg(QObject::tr("Open %1 in browser").arg(url.toHtmlEscaped())));
181 16 : button->setStyleSheet(QString::fromLatin1(kButtonStyle));
182 16 : button->setCursor(Qt::PointingHandCursor);
183 16 : QObject::connect(button, &QPushButton::clicked, button, [url]() {
184 : // Re-validate: never hand an unvalidated string to the OS URL handler.
185 0 : if (Util::isLaunchableWebUrl(url)) {
186 0 : QDesktopServices::openUrl(QUrl(url));
187 : }
188 0 : });
189 16 : return button;
190 : }
191 :
192 : } // namespace
193 :
194 3 : void PasswordDisplayPanel::setMarkedSecrets(const QStringList &names) {
195 : m_markedSecrets = names;
196 3 : }
197 :
198 177 : auto PasswordDisplayPanel::hides(const QString &field,
199 : const AppSettings &s) const -> bool {
200 177 : return s.hidePassword &&
201 127 : (s.hideAllFields || field == QObject::tr("Password") ||
202 213 : isSecret(field, s));
203 : }
204 :
205 43 : auto PasswordDisplayPanel::isSecret(const QString &field,
206 : const AppSettings &s) const -> bool {
207 43 : if (!s.hidePassword) {
208 : return false;
209 : }
210 : const bool marked =
211 38 : std::any_of(m_markedSecrets.cbegin(), m_markedSecrets.cend(),
212 : [&field](const QString &name) {
213 29 : return field.compare(name, Qt::CaseInsensitive) == 0;
214 : });
215 48 : return marked || (s.hideSecretFields &&
216 10 : FileContent::isSecretFieldName(field, s.secretFieldNames));
217 : }
218 :
219 121 : void PasswordDisplayPanel::addActionButtons(QLayout *layout,
220 : const QString &field,
221 : const QString &value,
222 : const AppSettings &s) {
223 121 : const QString buttonStyle = QString::fromLatin1(kButtonStyle);
224 121 : if (s.clipBoardType != Enums::CLIPBOARD_NEVER) {
225 25 : auto *copy = new QPushButtonWithClipboard(value, m_widgetParent);
226 25 : connect(copy, &QPushButtonWithClipboard::clicked, this,
227 25 : &PasswordDisplayPanel::copyRequested);
228 25 : copy->setStyleSheet(buttonStyle);
229 25 : layout->addWidget(copy);
230 : }
231 121 : if (s.useQrencode) {
232 10 : auto *qr = new QPushButtonAsQRCode(value, m_widgetParent);
233 10 : connect(qr, &QPushButtonAsQRCode::clicked, this,
234 10 : &PasswordDisplayPanel::qrRequested);
235 10 : qr->setStyleSheet(buttonStyle);
236 10 : layout->addWidget(qr);
237 : }
238 : // Never on the password field, nor on any field hidden like it: its value
239 : // must not reach a tooltip or the browser. A field shown in plain view
240 : // keeps its link.
241 289 : if (field != QObject::tr("Password") && !hides(field, s) &&
242 47 : Util::isLaunchableWebUrl(value)) {
243 16 : layout->addWidget(urlButton(value, m_widgetParent));
244 : }
245 121 : }
246 :
247 17 : void PasswordDisplayPanel::addHiddenPassword(QLayout *layout,
248 : const QString &field,
249 : const QString &value,
250 : const QString &lineStyle) {
251 : // Shown with the eye button, the password can be selected and copied;
252 : // SecretLineEdit marks that copy and reports it, as the value browsers do.
253 17 : auto *passwordLineEdit = new SecretLineEdit();
254 17 : connect(passwordLineEdit, &SecretLineEdit::copied, this,
255 17 : &PasswordDisplayPanel::textCopied);
256 17 : passwordLineEdit->setObjectName(field);
257 17 : passwordLineEdit->setText(value);
258 17 : passwordLineEdit->setReadOnly(true);
259 17 : passwordLineEdit->setStyleSheet(lineStyle);
260 17 : passwordLineEdit->setContentsMargins(0, 0, 0, 0);
261 17 : passwordLineEdit->setEchoMode(QLineEdit::Password);
262 : auto *showButton =
263 17 : new QPushButtonShowPassword(passwordLineEdit, m_widgetParent);
264 17 : showButton->setStyleSheet(QString::fromLatin1(kButtonStyle));
265 17 : showButton->setContentsMargins(0, 0, 0, 0);
266 17 : layout->addWidget(showButton);
267 17 : layout->addWidget(passwordLineEdit);
268 17 : }
269 :
270 121 : void PasswordDisplayPanel::addField(int position, const QString &field,
271 : const QString &value,
272 : const AppSettings &s) {
273 : const QString trimmedField = field.trimmed();
274 : // The password row keeps its value exactly: a leading or trailing space is
275 : // part of the password, so the copy button, QR code and revealed text must
276 : // return what is stored, as Ctrl+C on the tree and the edit dialog do. Only
277 : // a trailing CR from a CRLF-stored entry is dropped — it is a line ending,
278 : // not part of the secret. Every other row is `name: value`, where the
279 : // whitespace around the value is layout and is trimmed.
280 121 : QString displayValue;
281 121 : if (trimmedField == QObject::tr("Password")) {
282 65 : displayValue = value;
283 65 : if (displayValue.endsWith(QLatin1Char('\r'))) {
284 1 : displayValue.chop(1);
285 : }
286 : } else {
287 56 : displayValue = value.trimmed();
288 : }
289 121 : QFrame *frame = createFieldFrame();
290 121 : addActionButtons(frame->layout(), trimmedField, displayValue, s);
291 : const QString lineStyle =
292 121 : s.useMonospace
293 : ? "QLineEdit, QTextBrowser { border-style: none; background: "
294 : "transparent; font-family: monospace; }"
295 : : "QLineEdit, QTextBrowser { border-style: none; background: "
296 242 : "transparent; }";
297 : // The password, and with it the other secrets an entry holds (a PIN, a
298 : // CVV, an API key, what a template marks): behind the eye button too.
299 121 : if (hides(trimmedField, s)) {
300 17 : addHiddenPassword(frame->layout(), trimmedField, displayValue, lineStyle);
301 : } else {
302 : SecretTextBrowser *browser =
303 104 : valueBrowser(trimmedField, displayValue, lineStyle);
304 104 : connect(browser, &SecretTextBrowser::copied, this,
305 104 : &PasswordDisplayPanel::textCopied);
306 104 : frame->layout()->addWidget(browser);
307 : }
308 : // Plain text before the name is set: a field name comes from the entry,
309 : // and as rich text <img src="/dev/zero"> has Qt read that file without end.
310 121 : auto *label = new QLabel();
311 121 : label->setTextFormat(Qt::PlainText);
312 121 : label->setText(trimmedField);
313 121 : addRow(position, label, frame);
314 121 : }
315 :
316 136 : void PasswordDisplayPanel::addRow(int position, QLabel *label, QFrame *frame) {
317 136 : m_grid->addWidget(label, position, 0);
318 136 : m_grid->addWidget(frame, position, 1);
319 136 : label->installEventFilter(this);
320 136 : frame->installEventFilter(this);
321 : // The value widgets swallow mouse events before the frame sees them.
322 1032 : for (QWidget *child : frame->findChildren<QWidget *>()) {
323 760 : child->installEventFilter(this);
324 : }
325 136 : }
326 :
327 1075 : auto PasswordDisplayPanel::eventFilter(QObject *watched, QEvent *event)
328 : -> bool {
329 1075 : if (event->type() == QEvent::MouseButtonDblClick &&
330 : static_cast<QMouseEvent *>(event)->button() == Qt::LeftButton) {
331 2 : emit editRequested();
332 2 : return true;
333 : }
334 1073 : return QObject::eventFilter(watched, event);
335 : }
336 :
337 : // The colour is baked into the stylesheet (a `palette(mid)` reference
338 : // resolves once at polish time), so refreshPalette() re-runs this.
339 144 : void PasswordDisplayPanel::applyFrameStyle(QFrame *frame) const {
340 : const QString borderColor =
341 288 : m_widgetParent->palette().color(QPalette::Mid).name();
342 288 : frame->setStyleSheet(QStringLiteral(".QFrame{border: 1px solid %1; "
343 : "border-radius: 5px;}")
344 432 : .arg(borderColor));
345 144 : }
346 :
347 10 : void PasswordDisplayPanel::refreshPalette() {
348 18 : for (int i = 0; i < m_grid->count(); ++i) {
349 16 : if (auto *frame = qobject_cast<QFrame *>(m_grid->itemAt(i)->widget())) {
350 8 : applyFrameStyle(frame);
351 : }
352 : }
353 10 : }
354 :
355 : // Shared by addField() and addOtpField() so they cannot drift apart on
356 : // spacing or border colour.
357 136 : auto PasswordDisplayPanel::createFieldFrame() -> QFrame * {
358 136 : auto *frame = new QFrame();
359 136 : auto *frameLayout = new QHBoxLayout();
360 136 : frameLayout->setContentsMargins(5, 2, 2, 2);
361 136 : frameLayout->setSpacing(0);
362 136 : frame->setLayout(frameLayout);
363 136 : applyFrameStyle(frame);
364 136 : return frame;
365 : }
366 :
367 : // One label plus one frame, like every row, so grid rows match
368 : // displayFields()' position counter. hidePassword does not apply (the code
369 : // expires in seconds, next to a visible countdown), and there is no QR
370 : // button: a QR of the configuration would show the shared secret.
371 17 : void PasswordDisplayPanel::addOtpField(int position, const QString &otpConfig,
372 : const AppSettings &s) {
373 17 : const std::optional<Totp::Settings> settings = Totp::parse(otpConfig);
374 17 : if (!settings.has_value()) {
375 : // Report the problem without echoing what the user stored: the value is
376 : // still a would-be secret, so it gets neither a copy nor a QR button.
377 2 : AppSettings inert = s;
378 2 : inert.clipBoardType = Enums::CLIPBOARD_NEVER;
379 2 : inert.useQrencode = false;
380 2 : inert.hidePassword = false; // a message, never behind the eye button
381 4 : addField(position, QObject::tr("OTP code"),
382 2 : QObject::tr("No OTP code found in this password entry"), inert);
383 : return;
384 2 : }
385 :
386 15 : auto *frame = createFieldFrame();
387 : auto *otpWidget =
388 15 : new OtpCodeWidget(*settings, s.clipBoardType != Enums::CLIPBOARD_NEVER,
389 15 : s.useMonospace, frame);
390 15 : connect(otpWidget, &OtpCodeWidget::copyRequested, this,
391 15 : &PasswordDisplayPanel::copyRequested);
392 15 : frame->layout()->addWidget(otpWidget);
393 :
394 30 : addRow(position, new QLabel(QObject::tr("OTP code")), frame);
395 : }
|