diff options
Diffstat (limited to 'src/gui/reports/ReportsWidgetHibp.cpp')
-rw-r--r-- | src/gui/reports/ReportsWidgetHibp.cpp | 404 |
1 files changed, 404 insertions, 0 deletions
diff --git a/src/gui/reports/ReportsWidgetHibp.cpp b/src/gui/reports/ReportsWidgetHibp.cpp new file mode 100644 index 000000000..48e36518d --- /dev/null +++ b/src/gui/reports/ReportsWidgetHibp.cpp @@ -0,0 +1,404 @@ +/* + * Copyright (C) 2020 KeePassXC Team <team@keepassxc.org> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +#include "ReportsWidgetHibp.h" +#include "ui_ReportsWidgetHibp.h" + +#include "config-keepassx.h" +#include "core/Database.h" +#include "core/Global.h" +#include "core/Group.h" +#include "core/PasswordHealth.h" +#include "core/Resources.h" +#include "gui/MessageBox.h" + +#include <QMenu> +#include <QSortFilterProxyModel> +#include <QStandardItemModel> + +namespace +{ + /* + * Check if an entry has been marked as "known bad password". + * These entries are to be excluded from the HIBP report. + * + * Question to reviewer: Should this be a member function of Entry? + * It's duplicated in EditEntryWidget::setForms, EditEntryWidget::updateEntryData, + * ReportsWidgetHealthcheck::customMenuRequested, and Health::Item::Item. + */ + bool isKnownBad(const Entry* entry) + { + return entry->customData()->contains(PasswordHealth::OPTION_KNOWN_BAD) + && entry->customData()->value(PasswordHealth::OPTION_KNOWN_BAD) == TRUE_STR; + } +} // namespace + +ReportsWidgetHibp::ReportsWidgetHibp(QWidget* parent) + : QWidget(parent) + , m_ui(new Ui::ReportsWidgetHibp()) + , m_referencesModel(new QStandardItemModel(this)) + , m_modelProxy(new QSortFilterProxyModel(this)) +{ + m_ui->setupUi(this); + + m_modelProxy->setSourceModel(m_referencesModel.data()); + m_ui->hibpTableView->setModel(m_modelProxy.data()); + m_ui->hibpTableView->setSelectionMode(QAbstractItemView::NoSelection); + m_ui->hibpTableView->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + m_ui->hibpTableView->setSortingEnabled(true); + + connect(m_ui->hibpTableView, SIGNAL(doubleClicked(QModelIndex)), SLOT(emitEntryActivated(QModelIndex))); + connect(m_ui->hibpTableView, SIGNAL(customContextMenuRequested(QPoint)), SLOT(customMenuRequested(QPoint))); + connect(m_ui->showKnownBadCheckBox, SIGNAL(stateChanged(int)), this, SLOT(makeHibpTable())); +#ifdef WITH_XC_NETWORKING + connect(&m_downloader, SIGNAL(hibpResult(QString, int)), SLOT(addHibpResult(QString, int))); + connect(&m_downloader, SIGNAL(fetchFailed(QString)), SLOT(fetchFailed(QString))); + + connect(m_ui->validationButton, &QPushButton::pressed, [this] { startValidation(); }); +#endif +} + +ReportsWidgetHibp::~ReportsWidgetHibp() +{ +} + +void ReportsWidgetHibp::loadSettings(QSharedPointer<Database> db) +{ + // Re-initialize + m_db = std::move(db); + m_referencesModel->clear(); + m_pwndPasswords.clear(); + m_error.clear(); + m_rowToEntry.clear(); + m_editedEntry = nullptr; +#ifdef WITH_XC_NETWORKING + m_ui->stackedWidget->setCurrentIndex(0); + m_ui->validationButton->setEnabled(true); + m_ui->progressBar->hide(); +#else + // Compiled without networking, can't do anything + m_ui->stackedWidget->setCurrentIndex(2); +#endif +} + +/* + * Fill the table will all entries that have passwords that we've + * found to have been pwned. + */ +void ReportsWidgetHibp::makeHibpTable() +{ + // Reset the table + m_referencesModel->clear(); + m_rowToEntry.clear(); + + // If there were no findings, display a motivational message + if (m_pwndPasswords.isEmpty() && m_error.isEmpty()) { + m_referencesModel->setHorizontalHeaderLabels(QStringList() << tr("Congratulations, no exposed passwords!")); + m_ui->stackedWidget->setCurrentIndex(1); + return; + } + + // Standard header labels for found issues + m_referencesModel->setHorizontalHeaderLabels(QStringList() << tr("Title") << tr("Path") << tr("Password exposed…")); + + // Search database for passwords that we've found so far + QList<QPair<const Entry*, int>> items; + for (const auto* entry : m_db->rootGroup()->entriesRecursive()) { + if (!entry->isRecycled()) { + const auto found = m_pwndPasswords.find(entry->password()); + if (found != m_pwndPasswords.end()) { + items.append({entry, found.value()}); + } + } + } + + // Sort decending by the number the password has been exposed + qSort(items.begin(), items.end(), [](QPair<const Entry*, int>& lhs, QPair<const Entry*, int>& rhs) { + return lhs.second > rhs.second; + }); + + // Display entries that are marked as "known bad"? + const auto showKnownBad = m_ui->showKnownBadCheckBox->isChecked(); + + // The colors for table cells + const auto red = QBrush("red"); + + // Build the table + bool anyKnownBad = false; + for (const auto& item : items) { + const auto entry = item.first; + const auto group = entry->group(); + const auto count = item.second; + auto title = entry->title(); + + // If the entry is marked as known bad, hide it unless the + // checkbox is set. + bool knownBad = isKnownBad(entry); + if (knownBad) { + anyKnownBad = true; + if (!showKnownBad) { + continue; + } + + title.append(tr(" (Excluded)")); + } + + auto row = QList<QStandardItem*>(); + row << new QStandardItem(entry->iconPixmap(), title) + << new QStandardItem(group->iconPixmap(), group->hierarchy().join("/")) + << new QStandardItem(countToText(count)); + + if (knownBad) { + row[1]->setToolTip(tr("This entry is being excluded from reports")); + } + + row[2]->setForeground(red); + m_referencesModel->appendRow(row); + + // Store entry pointer per table row (used in double click handler) + m_rowToEntry.append(entry); + } + + // If there was an error, append the error message to the table + if (!m_error.isEmpty()) { + auto row = QList<QStandardItem*>(); + row << new QStandardItem(m_error); + m_referencesModel->appendRow(row); + row[0]->setForeground(QBrush(QColor("red"))); + } + + // If we're done and everything is good, display a motivational message +#ifdef WITH_XC_NETWORKING + if (m_downloader.passwordsRemaining() == 0 && m_pwndPasswords.isEmpty() && m_error.isEmpty()) { + m_referencesModel->clear(); + m_referencesModel->setHorizontalHeaderLabels(QStringList() << tr("Congratulations, no exposed passwords!")); + } +#endif + + // Show the "show known bad entries" checkbox if there's any known + // bad entry in the database. + if (anyKnownBad) { + m_ui->showKnownBadCheckBox->show(); + } else { + m_ui->showKnownBadCheckBox->hide(); + } + + m_ui->hibpTableView->resizeRowsToContents(); + + m_ui->stackedWidget->setCurrentIndex(1); +} + +/* + * Invoked when the downloader has finished checking one password. + */ +void ReportsWidgetHibp::addHibpResult(const QString& password, int count) +{ + // Add the password to the list of our findings if it has been pwned + if (count > 0) { + m_pwndPasswords[password] = count; + } + +#ifdef WITH_XC_NETWORKING + // Update the progress bar + int remaining = m_downloader.passwordsRemaining(); + if (remaining > 0) { + m_ui->progressBar->setValue(m_ui->progressBar->maximum() - remaining); + } else { + // Finished, remove the progress bar and build the table + m_ui->progressBar->hide(); + makeHibpTable(); + } +#endif +} + +/* + * Invoked when a query to the HIBP server fails. + * + * Displays the table with the current findings. + */ +void ReportsWidgetHibp::fetchFailed(const QString& error) +{ + m_error = error; + m_ui->progressBar->hide(); + makeHibpTable(); +} + +/* + * Add passwords to the downloader and start the actual online validation. + */ +void ReportsWidgetHibp::startValidation() +{ +#ifdef WITH_XC_NETWORKING + // Collect all passwords in the database (unless recycled, and + // unless empty, and unless marked as "known bad") and submit them + // to the downloader. + for (const auto* entry : m_db->rootGroup()->entriesRecursive()) { + if (!entry->isRecycled() && !entry->password().isEmpty()) { + m_downloader.add(entry->password()); + } + } + + // Short circuit if we didn't actually add any passwords + if (m_downloader.passwordsToValidate() == 0) { + makeHibpTable(); + return; + } + + // Store the number of passwords we need to check for the progress bar + m_ui->progressBar->show(); + m_ui->progressBar->setMaximum(m_downloader.passwordsToValidate()); + m_ui->validationButton->setEnabled(false); + + m_downloader.validate(); +#endif +} + +/* + * Convert the number of times a password has been pwned into + * a display text for the third table column. + */ +QString ReportsWidgetHibp::countToText(int count) +{ + if (count == 1) { + return tr("once"); + } else if (count <= 10) { + return tr("up to 10 times"); + } else if (count <= 100) { + return tr("up to 100 times"); + } else if (count <= 1000) { + return tr("up to 1000 times"); + } else if (count <= 10000) { + return tr("up to 10,000 times"); + } else if (count <= 100000) { + return tr("up to 100,000 times"); + } else if (count <= 1000000) { + return tr("up to a million times"); + } + + return tr("millions of times"); +} + +/* + * Double-click handler + */ +void ReportsWidgetHibp::emitEntryActivated(const QModelIndex& index) +{ + if (!index.isValid()) { + return; + } + + // Find which database entry was double-clicked + auto mappedIndex = m_modelProxy->mapToSource(index); + const auto entry = m_rowToEntry[mappedIndex.row()]; + if (entry) { + // Found it, invoke entry editor + m_editedEntry = entry; + m_editedPassword = entry->password(); + m_editedKnownBad = isKnownBad(entry); + emit entryActivated(const_cast<Entry*>(entry)); + } +} + +/* + * Invoked after "OK" was clicked in the entry editor. + * Re-validates the edited entry's new password. + */ +void ReportsWidgetHibp::refreshAfterEdit() +{ + // Sanity check + if (!m_editedEntry) { + return; + } + + // No need to re-validate if there was no change that affects + // the HIBP result (i. e., change to the password or to the + // "known bad" flag) + if (m_editedEntry->password() == m_editedPassword && isKnownBad(m_editedEntry) == m_editedKnownBad) { + // Don't go through HIBP but still rebuild the table, the user might + // have edited the entry title. + makeHibpTable(); + return; + } + + // Remove the previous password from the list of findings + m_pwndPasswords.remove(m_editedPassword); + + // Validate the new password against HIBP +#ifdef WITH_XC_NETWORKING + m_downloader.add(m_editedEntry->password()); + m_downloader.validate(); +#endif + + m_editedEntry = nullptr; +} + +void ReportsWidgetHibp::customMenuRequested(QPoint pos) +{ + + // Find which entry has been clicked + const auto index = m_ui->hibpTableView->indexAt(pos); + if (!index.isValid()) { + return; + } + auto mappedIndex = m_modelProxy->mapToSource(index); + m_contextmenuEntry = const_cast<Entry*>(m_rowToEntry[mappedIndex.row()]); + if (!m_contextmenuEntry) { + return; + } + + // Create the context menu + const auto menu = new QMenu(this); + + // Create the "edit entry" menu item + const auto edit = new QAction(Resources::instance()->icon("entry-edit"), tr("Edit Entry..."), this); + menu->addAction(edit); + connect(edit, SIGNAL(triggered()), SLOT(editFromContextmenu())); + + // Create the "exclude from reports" menu item + const auto knownbad = new QAction(Resources::instance()->icon("reports-exclude"), tr("Exclude from reports"), this); + knownbad->setCheckable(true); + knownbad->setChecked(m_contextmenuEntry->customData()->contains(PasswordHealth::OPTION_KNOWN_BAD) + && m_contextmenuEntry->customData()->value(PasswordHealth::OPTION_KNOWN_BAD) == TRUE_STR); + menu->addAction(knownbad); + connect(knownbad, SIGNAL(toggled(bool)), SLOT(toggleKnownBad(bool))); + + // Show the context menu + menu->popup(m_ui->hibpTableView->viewport()->mapToGlobal(pos)); +} + +void ReportsWidgetHibp::editFromContextmenu() +{ + if (m_contextmenuEntry) { + emit entryActivated(m_contextmenuEntry); + } +} + +void ReportsWidgetHibp::toggleKnownBad(bool isKnownBad) +{ + if (!m_contextmenuEntry) { + return; + } + + m_contextmenuEntry->customData()->set(PasswordHealth::OPTION_KNOWN_BAD, isKnownBad ? TRUE_STR : FALSE_STR); + + makeHibpTable(); +} + +void ReportsWidgetHibp::saveSettings() +{ + // nothing to do - the tab is passive +} |