From 11ffde5a9bc4396fd0002c6fefba3d543f272952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfram=20R=C3=B6sler?= Date: Sat, 1 Feb 2020 08:42:34 -0500 Subject: [PATCH 1/2] Implement Password Health Report Introduce a password health check to the application that evaluates every entry in a database. Entries that fail various tests are listed for user review and action. Also moves the statistics panel to the new Database -> Reports widget. Recycled entries are excluded from the results. We now have two classes, PasswordHealth to deal with a single password and HealthChecker to deal with all passwords of a database. Tests include passwords that are expired, re-used, and weak. * Closes #551 * Move zxcvbn usage to a centralized class (PasswordHealth) and replace its usages across the application to ensure standardized interpretation of entropy calculations. * Add new icons for the database reports view * Updated the demo database to show off the reports --- share/demo.kdbx | Bin 25109 -> 38965 bytes .../application/scalable/actions/health.svg | 1 + src/CMakeLists.txt | 9 +- src/browser/BrowserSettings.cpp | 3 +- src/cli/Estimate.cpp | 6 +- src/core/PasswordGenerator.cpp | 6 - src/core/PasswordGenerator.h | 1 - src/core/PasswordHealth.cpp | 188 ++++++++++++++ src/core/PasswordHealth.h | 113 +++++++++ src/gui/AboutDialog.cpp | 2 +- src/gui/DatabaseTabWidget.cpp | 5 + src/gui/DatabaseTabWidget.h | 1 + src/gui/DatabaseWidget.cpp | 11 + src/gui/DatabaseWidget.h | 3 + src/gui/MainWindow.cpp | 5 + src/gui/MainWindow.ui | 15 ++ src/gui/PasswordGeneratorWidget.cpp | 38 +-- src/gui/PasswordGeneratorWidget.h | 3 +- src/gui/dbsettings/DatabaseSettingsDialog.cpp | 3 - src/gui/reports/ReportsDialog.cpp | 128 ++++++++++ src/gui/reports/ReportsDialog.h | 85 +++++++ src/gui/reports/ReportsDialog.ui | 43 ++++ src/gui/reports/ReportsPageHealthcheck.cpp | 55 ++++ src/gui/reports/ReportsPageHealthcheck.h | 41 +++ .../ReportsPageStatistics.cpp} | 22 +- .../ReportsPageStatistics.h} | 10 +- src/gui/reports/ReportsWidget.cpp | 44 ++++ src/gui/reports/ReportsWidget.h | 53 ++++ src/gui/reports/ReportsWidgetHealthcheck.cpp | 237 ++++++++++++++++++ src/gui/reports/ReportsWidgetHealthcheck.h | 70 ++++++ src/gui/reports/ReportsWidgetHealthcheck.ui | 79 ++++++ .../ReportsWidgetStatistics.cpp} | 38 +-- .../ReportsWidgetStatistics.h} | 16 +- .../ReportsWidgetStatistics.ui} | 4 +- tests/CMakeLists.txt | 3 + tests/TestPasswordHealth.cpp | 65 +++++ tests/TestPasswordHealth.h | 32 +++ utils/makeicons.sh | 1 + 38 files changed, 1364 insertions(+), 75 deletions(-) create mode 100644 share/icons/application/scalable/actions/health.svg create mode 100644 src/core/PasswordHealth.cpp create mode 100644 src/core/PasswordHealth.h create mode 100644 src/gui/reports/ReportsDialog.cpp create mode 100644 src/gui/reports/ReportsDialog.h create mode 100644 src/gui/reports/ReportsDialog.ui create mode 100644 src/gui/reports/ReportsPageHealthcheck.cpp create mode 100644 src/gui/reports/ReportsPageHealthcheck.h rename src/gui/{dbsettings/DatabaseSettingsPageStatistics.cpp => reports/ReportsPageStatistics.cpp} (57%) rename src/gui/{dbsettings/DatabaseSettingsPageStatistics.h => reports/ReportsPageStatistics.h} (78%) create mode 100644 src/gui/reports/ReportsWidget.cpp create mode 100644 src/gui/reports/ReportsWidget.h create mode 100644 src/gui/reports/ReportsWidgetHealthcheck.cpp create mode 100644 src/gui/reports/ReportsWidgetHealthcheck.h create mode 100644 src/gui/reports/ReportsWidgetHealthcheck.ui rename src/gui/{dbsettings/DatabaseSettingsWidgetStatistics.cpp => reports/ReportsWidgetStatistics.cpp} (86%) rename src/gui/{dbsettings/DatabaseSettingsWidgetStatistics.h => reports/ReportsWidgetStatistics.h} (74%) rename src/gui/{dbsettings/DatabaseSettingsWidgetStatistics.ui => reports/ReportsWidgetStatistics.ui} (94%) create mode 100644 tests/TestPasswordHealth.cpp create mode 100644 tests/TestPasswordHealth.h diff --git a/share/demo.kdbx b/share/demo.kdbx index 71795676a953bfa2f88364f8ca050801955b61e6..1f372710486e39a33ec9aceef80be303f1e60f07 100644 GIT binary patch literal 38965 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 literal 25109 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 G0000tmd2g{ diff --git a/share/icons/application/scalable/actions/health.svg b/share/icons/application/scalable/actions/health.svg new file mode 100644 index 0000000000..4cd5fa0917 --- /dev/null +++ b/share/icons/application/scalable/actions/health.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index af9b9bb586..6b3d9abfab 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -48,6 +48,7 @@ set(keepassx_SOURCES core/Merger.cpp core/Metadata.cpp core/PasswordGenerator.cpp + core/PasswordHealth.cpp core/PassphraseGenerator.cpp core/SignalMultiplexer.cpp core/ScreenLockListener.cpp @@ -149,8 +150,12 @@ set(keepassx_SOURCES gui/dbsettings/DatabaseSettingsWidgetMetaDataSimple.cpp gui/dbsettings/DatabaseSettingsWidgetEncryption.cpp gui/dbsettings/DatabaseSettingsWidgetMasterKey.cpp - gui/dbsettings/DatabaseSettingsWidgetStatistics.cpp - gui/dbsettings/DatabaseSettingsPageStatistics.cpp + gui/reports/ReportsWidget.cpp + gui/reports/ReportsDialog.cpp + gui/reports/ReportsWidgetHealthcheck.cpp + gui/reports/ReportsPageHealthcheck.cpp + gui/reports/ReportsWidgetStatistics.cpp + gui/reports/ReportsPageStatistics.cpp gui/settings/SettingsWidget.cpp gui/widgets/ElidedLabel.cpp gui/widgets/PopupHelpWidget.cpp diff --git a/src/browser/BrowserSettings.cpp b/src/browser/BrowserSettings.cpp index 9cb4e0735c..b49af7005b 100644 --- a/src/browser/BrowserSettings.cpp +++ b/src/browser/BrowserSettings.cpp @@ -19,6 +19,7 @@ #include "BrowserSettings.h" #include "core/Config.h" +#include "core/PasswordHealth.h" BrowserSettings* BrowserSettings::m_instance(nullptr); @@ -541,7 +542,7 @@ QJsonObject BrowserSettings::generatePassword() m_passwordGenerator.setCharClasses(passwordCharClasses()); m_passwordGenerator.setFlags(passwordGeneratorFlags()); const QString pw = m_passwordGenerator.generatePassword(); - password["entropy"] = m_passwordGenerator.estimateEntropy(pw); + password["entropy"] = PasswordHealth(pw).entropy(); password["password"] = pw; } else { m_passPhraseGenerator.setWordCount(passPhraseWordCount()); diff --git a/src/cli/Estimate.cpp b/src/cli/Estimate.cpp index a84e239630..3b75090571 100644 --- a/src/cli/Estimate.cpp +++ b/src/cli/Estimate.cpp @@ -19,6 +19,7 @@ #include "cli/Utils.h" #include "cli/TextStream.h" +#include "core/PasswordHealth.h" #include #include #include @@ -49,10 +50,9 @@ static void estimate(const char* pwd, bool advanced) { TextStream out(Utils::STDOUT, QIODevice::WriteOnly); - double e = 0.0; int len = static_cast(strlen(pwd)); if (!advanced) { - e = ZxcvbnMatch(pwd, nullptr, nullptr); + const auto e = PasswordHealth(pwd).entropy(); // clang-format off out << QObject::tr("Length %1").arg(len, 0) << '\t' << QObject::tr("Entropy %1").arg(e, 0, 'f', 3) << '\t' @@ -62,7 +62,7 @@ static void estimate(const char* pwd, bool advanced) int ChkLen = 0; ZxcMatch_t *info, *p; double m = 0.0; - e = ZxcvbnMatch(pwd, nullptr, &info); + const auto e = ZxcvbnMatch(pwd, nullptr, &info); for (p = info; p; p = p->Next) { m += p->Entrpy; } diff --git a/src/core/PasswordGenerator.cpp b/src/core/PasswordGenerator.cpp index e203af672b..ff271a4533 100644 --- a/src/core/PasswordGenerator.cpp +++ b/src/core/PasswordGenerator.cpp @@ -19,7 +19,6 @@ #include "PasswordGenerator.h" #include "crypto/Random.h" -#include const char* PasswordGenerator::DefaultExcludedChars = ""; @@ -31,11 +30,6 @@ PasswordGenerator::PasswordGenerator() { } -double PasswordGenerator::estimateEntropy(const QString& password) -{ - return ZxcvbnMatch(password.toLatin1(), nullptr, nullptr); -} - void PasswordGenerator::setLength(int length) { if (length <= 0) { diff --git a/src/core/PasswordGenerator.h b/src/core/PasswordGenerator.h index 22627d25ba..55418b4ba2 100644 --- a/src/core/PasswordGenerator.h +++ b/src/core/PasswordGenerator.h @@ -57,7 +57,6 @@ class PasswordGenerator public: PasswordGenerator(); - double estimateEntropy(const QString& password); void setLength(int length); void setCharClasses(const CharClasses& classes); void setFlags(const GeneratorFlags& flags); diff --git a/src/core/PasswordHealth.cpp b/src/core/PasswordHealth.cpp new file mode 100644 index 0000000000..58e4e42af5 --- /dev/null +++ b/src/core/PasswordHealth.cpp @@ -0,0 +1,188 @@ +/* + * Copyright (C) 2019 KeePassXC Team + * + * 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 . + */ + +#include +#include + +#include "Database.h" +#include "Entry.h" +#include "Group.h" +#include "PasswordHealth.h" +#include "zxcvbn.h" + +PasswordHealth::PasswordHealth(double entropy) + : m_score(entropy) + , m_entropy(entropy) +{ + switch (quality()) { + case Quality::Bad: + case Quality::Poor: + m_scoreReasons << QApplication::tr("Very weak password"); + m_scoreDetails << QApplication::tr("Password entropy is %1 bits").arg(QString::number(m_entropy, 'f', 2)); + break; + + case Quality::Weak: + m_scoreReasons << QApplication::tr("Weak password"); + m_scoreDetails << QApplication::tr("Password entropy is %1 bits").arg(QString::number(m_entropy, 'f', 2)); + break; + + default: + // No reason or details for good and excellent passwords + break; + } +} + +PasswordHealth::PasswordHealth(QString pwd) + : PasswordHealth(ZxcvbnMatch(pwd.toLatin1(), nullptr, nullptr)) +{ +} + +void PasswordHealth::setScore(int score) +{ + m_score = score; +} + +void PasswordHealth::adjustScore(int amount) +{ + m_score += amount; +} + +QString PasswordHealth::scoreReason() const +{ + return m_scoreReasons.join("\n"); +} + +void PasswordHealth::addScoreReason(QString reason) +{ + m_scoreReasons << reason; +} + +QString PasswordHealth::scoreDetails() const +{ + return m_scoreDetails.join("\n"); +} + +void PasswordHealth::addScoreDetails(QString details) +{ + m_scoreDetails.append(details); +} + +PasswordHealth::Quality PasswordHealth::quality() const +{ + if (m_score <= 0) { + return Quality::Bad; + } else if (m_score < 40) { + return Quality::Poor; + } else if (m_score < 65) { + return Quality::Weak; + } else if (m_score < 100) { + return Quality::Good; + } + return Quality::Excellent; +} + +/** + * This class provides additional information about password health + * than can be derived from the password itself (re-use, expiry). + */ +HealthChecker::HealthChecker(QSharedPointer db) +{ + // Build the cache of re-used passwords + for (const auto* entry : db->rootGroup()->entriesRecursive()) { + if (!entry->isRecycled()) { + m_reuse[entry->password()] + << QApplication::tr("Used in %1/%2").arg(entry->group()->hierarchy().join('/'), entry->title()); + } + } +} + +/** + * Call operator of the Health Checker class. + * + * Returns the health of the password in `entry`, considering + * password entropy, re-use, expiration, etc. + */ +QSharedPointer HealthChecker::evaluate(const Entry* entry) +{ + if (!entry) { + return {}; + } + + // Return from cache if we saw it before + if (m_cache.contains(entry->uuid())) { + return m_cache[entry->uuid()]; + } + + // First analyse the password itself + const auto pwd = entry->password(); + auto health = QSharedPointer(new PasswordHealth(pwd)); + + // Second, if the password is in the database more than once, + // reduce the score accordingly + const auto& used = m_reuse[pwd]; + const auto count = used.size(); + if (count > 1) { + constexpr auto penalty = 15; + health->adjustScore(-penalty * (count - 1)); + health->addScoreReason(QApplication::tr("Password is used %1 times").arg(QString::number(count))); + // Add the first 20 uses of the password to prevent the details display from growing too large + for (int i = 0; i < used.size(); ++i) { + health->addScoreDetails(used[i]); + if (i == 19) { + health->addScoreDetails(QStringLiteral("...")); + break; + } + } + + // Don't allow re-used passwords to be considered "good" + // no matter how great their entropy is. + if (health->score() > 64) { + health->setScore(64); + } + } + + // Third, if the password has already expired, reduce score to 0; + // or, if the password is going to expire in the next 30 days, + // reduce score by 2 points per day. + if (entry->isExpired()) { + health->setScore(0); + health->addScoreReason(QApplication::tr("Password has expired")); + health->addScoreDetails(QApplication::tr("Password expiry was %1") + .arg(entry->timeInfo().expiryTime().toString(Qt::DefaultLocaleShortDate))); + } else if (entry->timeInfo().expires()) { + const auto days = QDateTime::currentDateTime().daysTo(entry->timeInfo().expiryTime()); + if (days <= 30) { + // First bring the score down into the "weak" range + // so that the entry appears in Health Check. Then + // reduce the score by 2 points for every day that + // we get closer to expiry. days<=0 has already + // been handled above ("isExpired()"). + if (health->score() > 60) { + health->setScore(60); + } + health->adjustScore((30 - days) * -2); + health->addScoreReason(days <= 2 ? QApplication::tr("Password is about to expire") + : days <= 10 ? QApplication::tr("Password expires in %1 days").arg(days) + : QApplication::tr("Password will expire soon")); + health->addScoreDetails(QApplication::tr("Password expires on %1") + .arg(entry->timeInfo().expiryTime().toString(Qt::DefaultLocaleShortDate))); + } + } + + // Return the result + return m_cache.insert(entry->uuid(), health).value(); +} diff --git a/src/core/PasswordHealth.h b/src/core/PasswordHealth.h new file mode 100644 index 0000000000..ca7f0236ec --- /dev/null +++ b/src/core/PasswordHealth.h @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2019 KeePassXC Team + * + * 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 . + */ + +#ifndef KEEPASSX_PASSWORDHEALTH_H +#define KEEPASSX_PASSWORDHEALTH_H + +#include +#include +#include + +class Database; +class Entry; + +/** + * Health status of a single password. + * + * @see HealthChecker + */ +class PasswordHealth +{ +public: + explicit PasswordHealth(double entropy); + explicit PasswordHealth(QString pwd); + + /* + * The password score is defined to be the greater the better + * (more secure) the password is. It doesn't have a dimension, + * there are no defined maximum or minimum values, and score + * values may change with different versions of the software. + */ + int score() const + { + return m_score; + } + + void setScore(int score); + void adjustScore(int amount); + + /* + * A text description for the password's quality assessment + * (translated into the application language), and additional + * information. Empty if nothing is wrong with the password. + * May contain more than line, separated by '\n'. + */ + QString scoreReason() const; + void addScoreReason(QString reason); + + QString scoreDetails() const; + void addScoreDetails(QString details); + + /* + * The password quality assessment (based on the score). + */ + enum class Quality + { + Bad, + Poor, + Weak, + Good, + Excellent + }; + Quality quality() const; + + /* + * The password's raw entropy value, in bits. + */ + double entropy() const + { + return m_entropy; + } + +private: + int m_score = 0; + double m_entropy = 0.0; + QStringList m_scoreReasons; + QStringList m_scoreDetails; +}; + +/** + * Password health check for all entries of a database. + * + * @see PasswordHealth + */ +class HealthChecker +{ +public: + explicit HealthChecker(QSharedPointer); + + // Get the health status of an entry in the database + QSharedPointer evaluate(const Entry* entry); + +private: + // Result cache (first=entry UUID) + QHash> m_cache; + // first = password, second = entries that use it + QHash m_reuse; +}; + +#endif // KEEPASSX_PASSWORDHEALTH_H diff --git a/src/gui/AboutDialog.cpp b/src/gui/AboutDialog.cpp index 4b9fe5f858..bd24cf165b 100644 --- a/src/gui/AboutDialog.cpp +++ b/src/gui/AboutDialog.cpp @@ -76,7 +76,7 @@ static const QString aboutContributors = R"(
  • fonic (Entry Table View)
  • kylemanna (YubiKey)
  • c4rlo (Offline HIBP Checker)
  • -
  • wolframroesler (HTML Exporter)
  • +
  • wolframroesler (HTML Export, Statistics, Password Health)
  • mdaniel (OpVault Importer)
  • keithbennett (KeePassHTTP)
  • Typz (KeePassHTTP)
  • diff --git a/src/gui/DatabaseTabWidget.cpp b/src/gui/DatabaseTabWidget.cpp index c37e6c5ea6..7e158406b2 100644 --- a/src/gui/DatabaseTabWidget.cpp +++ b/src/gui/DatabaseTabWidget.cpp @@ -457,6 +457,11 @@ void DatabaseTabWidget::changeMasterKey() currentDatabaseWidget()->switchToMasterKeyChange(); } +void DatabaseTabWidget::changeReports() +{ + currentDatabaseWidget()->switchToReports(); +} + void DatabaseTabWidget::changeDatabaseSettings() { currentDatabaseWidget()->switchToDatabaseSettings(); diff --git a/src/gui/DatabaseTabWidget.h b/src/gui/DatabaseTabWidget.h index 5c55bc63c7..29019a2d29 100644 --- a/src/gui/DatabaseTabWidget.h +++ b/src/gui/DatabaseTabWidget.h @@ -78,6 +78,7 @@ public slots: void relockPendingDatabase(); void changeMasterKey(); + void changeReports(); void changeDatabaseSettings(); void performGlobalAutoType(); diff --git a/src/gui/DatabaseWidget.cpp b/src/gui/DatabaseWidget.cpp index eb33c09c0b..fd579b04a0 100644 --- a/src/gui/DatabaseWidget.cpp +++ b/src/gui/DatabaseWidget.cpp @@ -59,6 +59,7 @@ #include "gui/entry/EntryView.h" #include "gui/group/EditGroupWidget.h" #include "gui/group/GroupView.h" +#include "gui/reports/ReportsDialog.h" #include "keeshare/KeeShare.h" #include "touchid/TouchID.h" @@ -88,6 +89,7 @@ DatabaseWidget::DatabaseWidget(QSharedPointer db, QWidget* parent) , m_editEntryWidget(new EditEntryWidget(this)) , m_editGroupWidget(new EditGroupWidget(this)) , m_historyEditEntryWidget(new EditEntryWidget(this)) + , m_reportsDialog(new ReportsDialog(this)) , m_databaseSettingDialog(new DatabaseSettingsDialog(this)) , m_databaseOpenWidget(new DatabaseOpenWidget(this)) , m_keepass1OpenWidget(new KeePass1OpenWidget(this)) @@ -165,6 +167,7 @@ DatabaseWidget::DatabaseWidget(QSharedPointer db, QWidget* parent) m_editEntryWidget->setObjectName("editEntryWidget"); m_editGroupWidget->setObjectName("editGroupWidget"); m_csvImportWizard->setObjectName("csvImportWizard"); + m_reportsDialog->setObjectName("reportsDialog"); m_databaseSettingDialog->setObjectName("databaseSettingsDialog"); m_databaseOpenWidget->setObjectName("databaseOpenWidget"); m_keepass1OpenWidget->setObjectName("keepass1OpenWidget"); @@ -173,6 +176,7 @@ DatabaseWidget::DatabaseWidget(QSharedPointer db, QWidget* parent) addChildWidget(m_mainWidget); addChildWidget(m_editEntryWidget); addChildWidget(m_editGroupWidget); + addChildWidget(m_reportsDialog); addChildWidget(m_databaseSettingDialog); addChildWidget(m_historyEditEntryWidget); addChildWidget(m_databaseOpenWidget); @@ -196,6 +200,7 @@ DatabaseWidget::DatabaseWidget(QSharedPointer db, QWidget* parent) connect(m_editEntryWidget, SIGNAL(historyEntryActivated(Entry*)), SLOT(switchToHistoryView(Entry*))); connect(m_historyEditEntryWidget, SIGNAL(editFinished(bool)), SLOT(switchBackToEntryEdit())); connect(m_editGroupWidget, SIGNAL(editFinished(bool)), SLOT(switchToMainView(bool))); + connect(m_reportsDialog, SIGNAL(editFinished(bool)), SLOT(switchToMainView(bool))); connect(m_databaseSettingDialog, SIGNAL(editFinished(bool)), SLOT(switchToMainView(bool))); connect(m_databaseOpenWidget, SIGNAL(dialogFinished(bool)), SLOT(loadDatabase(bool))); connect(m_keepass1OpenWidget, SIGNAL(dialogFinished(bool)), SLOT(loadDatabase(bool))); @@ -1105,6 +1110,12 @@ void DatabaseWidget::entryActivationSignalReceived(Entry* entry, EntryModel::Mod } } +void DatabaseWidget::switchToReports() +{ + m_reportsDialog->load(m_db); + setCurrentWidget(m_reportsDialog); +} + void DatabaseWidget::switchToDatabaseSettings() { m_databaseSettingDialog->load(m_db); diff --git a/src/gui/DatabaseWidget.h b/src/gui/DatabaseWidget.h index 9f0c5c9765..6420a3b242 100644 --- a/src/gui/DatabaseWidget.h +++ b/src/gui/DatabaseWidget.h @@ -34,6 +34,7 @@ class DatabaseOpenWidget; class KeePass1OpenWidget; class OpVaultOpenWidget; class DatabaseSettingsDialog; +class ReportsDialog; class Database; class FileWatcher; class EditEntryWidget; @@ -181,6 +182,7 @@ public slots: void sortGroupsAsc(); void sortGroupsDesc(); void switchToMasterKeyChange(); + void switchToReports(); void switchToDatabaseSettings(); void switchToOpenDatabase(); void switchToOpenDatabase(const QString& filePath); @@ -251,6 +253,7 @@ private slots: QPointer m_editEntryWidget; QPointer m_editGroupWidget; QPointer m_historyEditEntryWidget; + QPointer m_reportsDialog; QPointer m_databaseSettingDialog; QPointer m_databaseOpenWidget; QPointer m_keepass1OpenWidget; diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index e9c150dd5c..2d52331ff3 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -332,6 +332,7 @@ MainWindow::MainWindow() m_ui->actionDatabaseSave->setIcon(filePath()->icon("actions", "document-save")); m_ui->actionDatabaseSaveAs->setIcon(filePath()->icon("actions", "document-save-as")); m_ui->actionDatabaseClose->setIcon(filePath()->icon("actions", "document-close")); + m_ui->actionReports->setIcon(filePath()->icon("actions", "help-about")); m_ui->actionChangeDatabaseSettings->setIcon(filePath()->icon("actions", "document-edit")); m_ui->actionChangeMasterKey->setIcon(filePath()->icon("actions", "database-change-key")); m_ui->actionLockDatabases->setIcon(filePath()->icon("actions", "database-lock")); @@ -403,6 +404,7 @@ MainWindow::MainWindow() connect(m_ui->actionDatabaseClose, SIGNAL(triggered()), m_ui->tabWidget, SLOT(closeCurrentDatabaseTab())); connect(m_ui->actionDatabaseMerge, SIGNAL(triggered()), m_ui->tabWidget, SLOT(mergeDatabase())); connect(m_ui->actionChangeMasterKey, SIGNAL(triggered()), m_ui->tabWidget, SLOT(changeMasterKey())); + connect(m_ui->actionReports, SIGNAL(triggered()), m_ui->tabWidget, SLOT(changeReports())); connect(m_ui->actionChangeDatabaseSettings, SIGNAL(triggered()), m_ui->tabWidget, SLOT(changeDatabaseSettings())); connect(m_ui->actionImportCsv, SIGNAL(triggered()), m_ui->tabWidget, SLOT(importCsv())); connect(m_ui->actionImportKeePass1, SIGNAL(triggered()), m_ui->tabWidget, SLOT(importKeePass1Database())); @@ -673,6 +675,7 @@ void MainWindow::setMenuActionState(DatabaseWidget::Mode mode) m_ui->actionGroupDownloadFavicons->setEnabled(groupSelected && currentGroupHasEntries && !recycleBinSelected); m_ui->actionChangeMasterKey->setEnabled(true); + m_ui->actionReports->setEnabled(true); m_ui->actionChangeDatabaseSettings->setEnabled(true); m_ui->actionDatabaseSave->setEnabled(m_ui->tabWidget->canSave()); m_ui->actionDatabaseSaveAs->setEnabled(true); @@ -719,6 +722,7 @@ void MainWindow::setMenuActionState(DatabaseWidget::Mode mode) } m_ui->actionChangeMasterKey->setEnabled(false); + m_ui->actionReports->setEnabled(false); m_ui->actionChangeDatabaseSettings->setEnabled(false); m_ui->actionDatabaseSave->setEnabled(false); m_ui->actionDatabaseSaveAs->setEnabled(false); @@ -746,6 +750,7 @@ void MainWindow::setMenuActionState(DatabaseWidget::Mode mode) } m_ui->actionChangeMasterKey->setEnabled(false); + m_ui->actionReports->setEnabled(false); m_ui->actionChangeDatabaseSettings->setEnabled(false); m_ui->actionDatabaseSave->setEnabled(false); m_ui->actionDatabaseSaveAs->setEnabled(false); diff --git a/src/gui/MainWindow.ui b/src/gui/MainWindow.ui index e09c91dd79..aec0efb37e 100644 --- a/src/gui/MainWindow.ui +++ b/src/gui/MainWindow.ui @@ -236,6 +236,7 @@ + @@ -532,6 +533,20 @@ Change master &key... + + + false + + + &Reports... + + + Statistics, health check, etc. + + + QAction::NoRole + + false diff --git a/src/gui/PasswordGeneratorWidget.cpp b/src/gui/PasswordGeneratorWidget.cpp index e0f8fbe5fc..c04487c0e4 100644 --- a/src/gui/PasswordGeneratorWidget.cpp +++ b/src/gui/PasswordGeneratorWidget.cpp @@ -26,6 +26,7 @@ #include "core/Config.h" #include "core/FilePath.h" #include "core/PasswordGenerator.h" +#include "core/PasswordHealth.h" #include "gui/Clipboard.h" PasswordGeneratorWidget::PasswordGeneratorWidget(QWidget* parent) @@ -261,21 +262,17 @@ void PasswordGeneratorWidget::updateButtonsEnabled(const QString& password) void PasswordGeneratorWidget::updatePasswordStrength(const QString& password) { - double entropy = 0.0; - if (m_ui->tabWidget->currentIndex() == Password) { - entropy = m_passwordGenerator->estimateEntropy(password); - } else { - entropy = m_dicewareGenerator->estimateEntropy(); + PasswordHealth health(password); + if (m_ui->tabWidget->currentIndex() == Diceware) { + // Diceware estimates entropy differently + health = PasswordHealth(m_dicewareGenerator->estimateEntropy()); } - m_ui->entropyLabel->setText(tr("Entropy: %1 bit").arg(QString::number(entropy, 'f', 2))); + m_ui->entropyLabel->setText(tr("Entropy: %1 bit").arg(QString::number(health.entropy(), 'f', 2))); - if (entropy > m_ui->entropyProgressBar->maximum()) { - entropy = m_ui->entropyProgressBar->maximum(); - } - m_ui->entropyProgressBar->setValue(entropy); + m_ui->entropyProgressBar->setValue(std::min(int(health.entropy()), m_ui->entropyProgressBar->maximum())); - colorStrengthIndicator(entropy); + colorStrengthIndicator(health); } void PasswordGeneratorWidget::applyPassword() @@ -384,7 +381,7 @@ void PasswordGeneratorWidget::excludeHexChars() m_ui->editExcludedChars->setText("GHIJKLMNOPQRSTUVWXYZghijklmnopqrstuvwxyz"); } -void PasswordGeneratorWidget::colorStrengthIndicator(double entropy) +void PasswordGeneratorWidget::colorStrengthIndicator(const PasswordHealth& health) { // Take the existing stylesheet and convert the text and background color to arguments QString style = m_ui->entropyProgressBar->styleSheet(); @@ -395,18 +392,27 @@ void PasswordGeneratorWidget::colorStrengthIndicator(double entropy) // Set the color and background based on entropy // colors are taking from the KDE breeze palette // - if (entropy < 40) { + switch (health.quality()) { + case PasswordHealth::Quality::Bad: + case PasswordHealth::Quality::Poor: m_ui->entropyProgressBar->setStyleSheet(style.arg("#c0392b")); m_ui->strengthLabel->setText(tr("Password Quality: %1").arg(tr("Poor", "Password quality"))); - } else if (entropy >= 40 && entropy < 65) { + break; + + case PasswordHealth::Quality::Weak: m_ui->entropyProgressBar->setStyleSheet(style.arg("#f39c1f")); m_ui->strengthLabel->setText(tr("Password Quality: %1").arg(tr("Weak", "Password quality"))); - } else if (entropy >= 65 && entropy < 100) { + break; + + case PasswordHealth::Quality::Good: m_ui->entropyProgressBar->setStyleSheet(style.arg("#11d116")); m_ui->strengthLabel->setText(tr("Password Quality: %1").arg(tr("Good", "Password quality"))); - } else { + break; + + case PasswordHealth::Quality::Excellent: m_ui->entropyProgressBar->setStyleSheet(style.arg("#27ae60")); m_ui->strengthLabel->setText(tr("Password Quality: %1").arg(tr("Excellent", "Password quality"))); + break; } } diff --git a/src/gui/PasswordGeneratorWidget.h b/src/gui/PasswordGeneratorWidget.h index b39a2f10f9..eba7f815f6 100644 --- a/src/gui/PasswordGeneratorWidget.h +++ b/src/gui/PasswordGeneratorWidget.h @@ -32,6 +32,7 @@ namespace Ui } class PasswordGenerator; +class PasswordHealth; class PassphraseGenerator; class PasswordGeneratorWidget : public QWidget @@ -77,7 +78,7 @@ private slots: void passwordSpinBoxChanged(); void dicewareSliderMoved(); void dicewareSpinBoxChanged(); - void colorStrengthIndicator(double entropy); + void colorStrengthIndicator(const PasswordHealth& health); void updateGenerator(); diff --git a/src/gui/dbsettings/DatabaseSettingsDialog.cpp b/src/gui/dbsettings/DatabaseSettingsDialog.cpp index 33c4df2c4d..e0e6765a46 100644 --- a/src/gui/dbsettings/DatabaseSettingsDialog.cpp +++ b/src/gui/dbsettings/DatabaseSettingsDialog.cpp @@ -19,7 +19,6 @@ #include "DatabaseSettingsDialog.h" #include "ui_DatabaseSettingsDialog.h" -#include "DatabaseSettingsPageStatistics.h" #include "DatabaseSettingsWidgetEncryption.h" #include "DatabaseSettingsWidgetGeneral.h" #include "DatabaseSettingsWidgetMasterKey.h" @@ -85,8 +84,6 @@ DatabaseSettingsDialog::DatabaseSettingsDialog(QWidget* parent) m_securityTabWidget->addTab(m_masterKeyWidget, tr("Master Key")); m_securityTabWidget->addTab(m_encryptionWidget, tr("Encryption Settings")); - addSettingsPage(new DatabaseSettingsPageStatistics()); - #if defined(WITH_XC_KEESHARE) addSettingsPage(new DatabaseSettingsPageKeeShare()); #endif diff --git a/src/gui/reports/ReportsDialog.cpp b/src/gui/reports/ReportsDialog.cpp new file mode 100644 index 0000000000..22ebab41a9 --- /dev/null +++ b/src/gui/reports/ReportsDialog.cpp @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2019 KeePassXC Team + * + * 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 . + */ + +#include "ReportsDialog.h" +#include "ui_ReportsDialog.h" + +#include "ReportsPageHealthcheck.h" +#include "ReportsPageStatistics.h" +#include "ReportsWidgetHealthcheck.h" + +#include "core/Global.h" +#include "touchid/TouchID.h" +#include +#include + +class ReportsDialog::ExtraPage +{ +public: + ExtraPage(QSharedPointer p, QWidget* w) + : page(p) + , widget(w) + { + } + void loadSettings(QSharedPointer db) const + { + page->loadSettings(widget, db); + } + void saveSettings() const + { + page->saveSettings(widget); + } + +private: + QSharedPointer page; + QWidget* widget; +}; + +ReportsDialog::ReportsDialog(QWidget* parent) + : DialogyWidget(parent) + , m_ui(new Ui::ReportsDialog()) + , m_healthPage(new ReportsPageHealthcheck()) + , m_statPage(new ReportsPageStatistics()) + , m_editEntryWidget(new EditEntryWidget(this)) +{ + m_ui->setupUi(this); + + connect(m_ui->buttonBox, SIGNAL(rejected()), SLOT(reject())); + addPage(m_healthPage); + addPage(m_statPage); + + m_ui->stackedWidget->setCurrentIndex(0); + + m_editEntryWidget->setObjectName("editEntryWidget"); + m_editEntryWidget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Ignored); + m_ui->stackedWidget->addWidget(m_editEntryWidget); + adjustSize(); + + connect(m_ui->categoryList, SIGNAL(categoryChanged(int)), m_ui->stackedWidget, SLOT(setCurrentIndex(int))); + connect(m_healthPage->m_healthWidget, + SIGNAL(entryActivated(const Group*, Entry*)), + SLOT(entryActivationSignalReceived(const Group*, Entry*))); + connect(m_editEntryWidget, SIGNAL(editFinished(bool)), SLOT(switchToMainView(bool))); +} + +ReportsDialog::~ReportsDialog() +{ +} + +void ReportsDialog::load(const QSharedPointer& db) +{ + m_ui->categoryList->setCurrentCategory(0); + for (const ExtraPage& page : asConst(m_extraPages)) { + page.loadSettings(db); + } + m_db = db; +} + +void ReportsDialog::addPage(QSharedPointer page) +{ + const auto category = m_ui->categoryList->currentCategory(); + const auto widget = page->createWidget(); + widget->setParent(this); + m_extraPages.append(ExtraPage(page, widget)); + m_ui->stackedWidget->addWidget(widget); + m_ui->categoryList->addCategory(page->name(), page->icon()); + m_ui->categoryList->setCurrentCategory(category); +} + +void ReportsDialog::reject() +{ + for (const ExtraPage& extraPage : asConst(m_extraPages)) { + extraPage.saveSettings(); + } + +#ifdef WITH_XC_TOUCHID + TouchID::getInstance().reset(m_db ? m_db->filePath() : ""); +#endif + + emit editFinished(true); +} + +void ReportsDialog::entryActivationSignalReceived(const Group* group, Entry* entry) +{ + m_editEntryWidget->loadEntry(entry, false, false, group->hierarchy().join(" > "), m_db); + m_ui->stackedWidget->setCurrentWidget(m_editEntryWidget); +} + +void ReportsDialog::switchToMainView(bool previousDialogAccepted) +{ + m_ui->stackedWidget->setCurrentWidget(m_healthPage->m_healthWidget); + if (previousDialogAccepted) { + m_healthPage->m_healthWidget->calculateHealth(); + } +} diff --git a/src/gui/reports/ReportsDialog.h b/src/gui/reports/ReportsDialog.h new file mode 100644 index 0000000000..7a53623c38 --- /dev/null +++ b/src/gui/reports/ReportsDialog.h @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2019 KeePassXC Team + * + * 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 . + */ + +#ifndef KEEPASSX_REPORTSWIDGET_H +#define KEEPASSX_REPORTSWIDGET_H + +#include "config-keepassx.h" +#include "gui/DialogyWidget.h" +#include "gui/entry/EditEntryWidget.h" + +#include +#include +#include + +class Database; +class Entry; +class Group; +class QTabWidget; +class ReportsPageHealthcheck; +class ReportsPageStatistics; + +namespace Ui +{ + class ReportsDialog; +} + +class IReportsPage +{ +public: + virtual ~IReportsPage() + { + } + virtual QString name() = 0; + virtual QIcon icon() = 0; + virtual QWidget* createWidget() = 0; + virtual void loadSettings(QWidget* widget, QSharedPointer db) = 0; + virtual void saveSettings(QWidget* widget) = 0; +}; + +class ReportsDialog : public DialogyWidget +{ + Q_OBJECT + +public: + explicit ReportsDialog(QWidget* parent = nullptr); + ~ReportsDialog() override; + Q_DISABLE_COPY(ReportsDialog); + + void load(const QSharedPointer& db); + void addPage(QSharedPointer page); + +signals: + void editFinished(bool accepted); + +private slots: + void reject(); + void entryActivationSignalReceived(const Group*, Entry* entry); + void switchToMainView(bool previousDialogAccepted); + +private: + QSharedPointer m_db; + const QScopedPointer m_ui; + const QSharedPointer m_healthPage; + const QSharedPointer m_statPage; + QPointer m_editEntryWidget; + + class ExtraPage; + QList m_extraPages; +}; + +#endif // KEEPASSX_REPORTSWIDGET_H diff --git a/src/gui/reports/ReportsDialog.ui b/src/gui/reports/ReportsDialog.ui new file mode 100644 index 0000000000..773981a101 --- /dev/null +++ b/src/gui/reports/ReportsDialog.ui @@ -0,0 +1,43 @@ + + + ReportsDialog + + + + + + + + + + + -1 + + + + + + + + + + + QDialogButtonBox::Close + + + + + + + + + + CategoryListWidget + QWidget +
    gui/CategoryListWidget.h
    + 1 +
    +
    + + +
    diff --git a/src/gui/reports/ReportsPageHealthcheck.cpp b/src/gui/reports/ReportsPageHealthcheck.cpp new file mode 100644 index 0000000000..41fa406258 --- /dev/null +++ b/src/gui/reports/ReportsPageHealthcheck.cpp @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2019 KeePassXC Team + * + * 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 . + */ + +#include "ReportsPageHealthcheck.h" + +#include "ReportsWidgetHealthcheck.h" +#include "core/FilePath.h" + +#include + +ReportsPageHealthcheck::ReportsPageHealthcheck() + : m_healthWidget(new ReportsWidgetHealthcheck()) +{ +} + +QString ReportsPageHealthcheck::name() +{ + return QApplication::tr("Health Check"); +} + +QIcon ReportsPageHealthcheck::icon() +{ + return FilePath::instance()->icon("actions", "health"); +} + +QWidget* ReportsPageHealthcheck::createWidget() +{ + return m_healthWidget; +} + +void ReportsPageHealthcheck::loadSettings(QWidget* widget, QSharedPointer db) +{ + const auto settingsWidget = reinterpret_cast(widget); + settingsWidget->loadSettings(db); +} + +void ReportsPageHealthcheck::saveSettings(QWidget* widget) +{ + const auto settingsWidget = reinterpret_cast(widget); + settingsWidget->saveSettings(); +} diff --git a/src/gui/reports/ReportsPageHealthcheck.h b/src/gui/reports/ReportsPageHealthcheck.h new file mode 100644 index 0000000000..8a85b2d20d --- /dev/null +++ b/src/gui/reports/ReportsPageHealthcheck.h @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2019 KeePassXC Team + * + * 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 . + */ + +#ifndef KEEPASSXC_REPORTSPAGEHEALTHCHECK_H +#define KEEPASSXC_REPORTSPAGEHEALTHCHECK_H + +#include + +#include "ReportsDialog.h" + +class ReportsWidgetHealthcheck; + +class ReportsPageHealthcheck : public IReportsPage +{ +public: + ReportsWidgetHealthcheck* m_healthWidget; + + ReportsPageHealthcheck(); + + QString name() override; + QIcon icon() override; + QWidget* createWidget() override; + void loadSettings(QWidget* widget, QSharedPointer db) override; + void saveSettings(QWidget* widget) override; +}; + +#endif // KEEPASSXC_REPORTSPAGEHEALTHCHECK_H diff --git a/src/gui/dbsettings/DatabaseSettingsPageStatistics.cpp b/src/gui/reports/ReportsPageStatistics.cpp similarity index 57% rename from src/gui/dbsettings/DatabaseSettingsPageStatistics.cpp rename to src/gui/reports/ReportsPageStatistics.cpp index 6fe24ff0f3..e4570e172d 100644 --- a/src/gui/dbsettings/DatabaseSettingsPageStatistics.cpp +++ b/src/gui/reports/ReportsPageStatistics.cpp @@ -15,38 +15,36 @@ * along with this program. If not, see . */ -#include "DatabaseSettingsPageStatistics.h" +#include "ReportsPageStatistics.h" -#include "DatabaseSettingsWidgetStatistics.h" -#include "core/Database.h" +#include "ReportsWidgetStatistics.h" #include "core/FilePath.h" -#include "core/Group.h" #include -QString DatabaseSettingsPageStatistics::name() +QString ReportsPageStatistics::name() { return QApplication::tr("Statistics"); } -QIcon DatabaseSettingsPageStatistics::icon() +QIcon ReportsPageStatistics::icon() { return FilePath::instance()->icon("actions", "statistics"); } -QWidget* DatabaseSettingsPageStatistics::createWidget() +QWidget* ReportsPageStatistics::createWidget() { - return new DatabaseSettingsWidgetStatistics(); + return new ReportsWidgetStatistics(); } -void DatabaseSettingsPageStatistics::loadSettings(QWidget* widget, QSharedPointer db) +void ReportsPageStatistics::loadSettings(QWidget* widget, QSharedPointer db) { - DatabaseSettingsWidgetStatistics* settingsWidget = reinterpret_cast(widget); + ReportsWidgetStatistics* settingsWidget = reinterpret_cast(widget); settingsWidget->loadSettings(db); } -void DatabaseSettingsPageStatistics::saveSettings(QWidget* widget) +void ReportsPageStatistics::saveSettings(QWidget* widget) { - DatabaseSettingsWidgetStatistics* settingsWidget = reinterpret_cast(widget); + ReportsWidgetStatistics* settingsWidget = reinterpret_cast(widget); settingsWidget->saveSettings(); } diff --git a/src/gui/dbsettings/DatabaseSettingsPageStatistics.h b/src/gui/reports/ReportsPageStatistics.h similarity index 78% rename from src/gui/dbsettings/DatabaseSettingsPageStatistics.h rename to src/gui/reports/ReportsPageStatistics.h index c890f3b81c..00d611ee34 100644 --- a/src/gui/dbsettings/DatabaseSettingsPageStatistics.h +++ b/src/gui/reports/ReportsPageStatistics.h @@ -15,14 +15,14 @@ * along with this program. If not, see . */ -#ifndef KEEPASSXC_DATABASESETTINGSPAGESTATISTICS_H -#define KEEPASSXC_DATABASESETTINGSPAGESTATISTICS_H +#ifndef KEEPASSXC_REPORTSPAGESTATISTICS_H +#define KEEPASSXC_REPORTSPAGESTATISTICS_H #include -#include "DatabaseSettingsDialog.h" +#include "ReportsDialog.h" -class DatabaseSettingsPageStatistics : public IDatabaseSettingsPage +class ReportsPageStatistics : public IReportsPage { public: QString name() override; @@ -32,4 +32,4 @@ class DatabaseSettingsPageStatistics : public IDatabaseSettingsPage void saveSettings(QWidget* widget) override; }; -#endif // KEEPASSXC_DATABASESETTINGSPAGESTATISTICS_H +#endif // KEEPASSXC_REPORTSPAGESTATISTICS_H diff --git a/src/gui/reports/ReportsWidget.cpp b/src/gui/reports/ReportsWidget.cpp new file mode 100644 index 0000000000..1844341160 --- /dev/null +++ b/src/gui/reports/ReportsWidget.cpp @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2018 KeePassXC Team + * + * 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 . + */ + +#include "ReportsWidget.h" + +ReportsWidget::ReportsWidget(QWidget* parent) + : SettingsWidget(parent) +{ +} + +ReportsWidget::~ReportsWidget() +{ +} + +/** + * Load the database to be configured by this page and initialize the page. + * The page will NOT take ownership of the database. + * + * @param db database object to be configured + */ +void ReportsWidget::load(QSharedPointer db) +{ + m_db = std::move(db); + initialize(); +} + +const QSharedPointer ReportsWidget::getDatabase() const +{ + return m_db; +} diff --git a/src/gui/reports/ReportsWidget.h b/src/gui/reports/ReportsWidget.h new file mode 100644 index 0000000000..631490405d --- /dev/null +++ b/src/gui/reports/ReportsWidget.h @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2018 KeePassXC Team + * + * 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 . + */ + +#ifndef KEEPASSXC_REPORTSWIDGET_H +#define KEEPASSXC_REPORTSWIDGET_H + +#include "gui/settings/SettingsWidget.h" + +#include + +class Database; + +/** + * Pure-virtual base class for KeePassXC database settings widgets. + */ +class ReportsWidget : public SettingsWidget +{ + Q_OBJECT + +public: + explicit ReportsWidget(QWidget* parent = nullptr); + Q_DISABLE_COPY(ReportsWidget); + ~ReportsWidget() override; + + virtual void load(QSharedPointer db); + + const QSharedPointer getDatabase() const; + +signals: + /** + * Can be emitted to indicate size changes and allow parents widgets to adjust properly. + */ + void sizeChanged(); + +protected: + QSharedPointer m_db; +}; + +#endif // KEEPASSXC_REPORTSWIDGET_H diff --git a/src/gui/reports/ReportsWidgetHealthcheck.cpp b/src/gui/reports/ReportsWidgetHealthcheck.cpp new file mode 100644 index 0000000000..c668b3495d --- /dev/null +++ b/src/gui/reports/ReportsWidgetHealthcheck.cpp @@ -0,0 +1,237 @@ +/* + * Copyright (C) 2019 KeePassXC Team + * + * 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 . + */ + +#include "ReportsWidgetHealthcheck.h" +#include "ui_ReportsWidgetHealthcheck.h" + +#include "core/AsyncTask.h" +#include "core/Database.h" +#include "core/FilePath.h" +#include "core/Group.h" +#include "core/PasswordHealth.h" + +#include +#include +#include + +namespace +{ + class Health + { + public: + struct Item + { + QPointer group; + QPointer entry; + QSharedPointer health; + + Item(const Group* g, const Entry* e, QSharedPointer h) + : group(g) + , entry(e) + , health(h) + { + } + + bool operator<(const Item& rhs) const + { + return health->score() < rhs.health->score(); + } + }; + + explicit Health(QSharedPointer); + + const QList>& items() const + { + return m_items; + } + + private: + QSharedPointer m_db; + HealthChecker m_checker; + QList> m_items; + }; +} // namespace + +Health::Health(QSharedPointer db) + : m_db(db) + , m_checker(db) +{ + for (const auto* group : db->rootGroup()->groupsRecursive(true)) { + // Skip recycle bin + if (group->isRecycled()) { + continue; + } + + for (const auto* entry : group->entries()) { + if (entry->isRecycled()) { + continue; + } + + // Skip entries with empty password + if (entry->password().isEmpty()) { + continue; + } + + // Add entry if its password isn't at least "good" + const auto item = QSharedPointer(new Item(group, entry, m_checker.evaluate(entry))); + if (item->health->quality() < PasswordHealth::Quality::Good) { + m_items.append(item); + } + } + } + + // Sort the result so that the worst passwords (least score) + // are at the top + std::sort(m_items.begin(), m_items.end(), [](QSharedPointer x, QSharedPointer y) { return *x < *y; }); +} + +ReportsWidgetHealthcheck::ReportsWidgetHealthcheck(QWidget* parent) + : QWidget(parent) + , m_ui(new Ui::ReportsWidgetHealthcheck()) + , m_errorIcon(FilePath::instance()->icon("status", "dialog-error")) +{ + m_ui->setupUi(this); + + m_referencesModel.reset(new QStandardItemModel()); + m_ui->healthcheckTableView->setModel(m_referencesModel.data()); + m_ui->healthcheckTableView->setSelectionMode(QAbstractItemView::NoSelection); + m_ui->healthcheckTableView->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + + connect(m_ui->healthcheckTableView, SIGNAL(doubleClicked(QModelIndex)), SLOT(emitEntryActivated(QModelIndex))); +} + +ReportsWidgetHealthcheck::~ReportsWidgetHealthcheck() +{ +} + +void ReportsWidgetHealthcheck::addHealthRow(QSharedPointer health, + const Group* group, + const Entry* entry) +{ + QString descr, tip; + QColor qualityColor; + const auto quality = health->quality(); + switch (quality) { + case PasswordHealth::Quality::Bad: + descr = tr("Bad", "Password quality"); + tip = tr("Bad — password must be changed"); + qualityColor.setNamedColor("red"); + break; + + case PasswordHealth::Quality::Poor: + descr = tr("Poor", "Password quality"); + tip = tr("Poor — password should be changed"); + qualityColor.setNamedColor("orange"); + break; + + case PasswordHealth::Quality::Weak: + descr = tr("Weak", "Password quality"); + tip = tr("Weak — consider changing the password"); + qualityColor.setNamedColor("yellow"); + break; + + case PasswordHealth::Quality::Good: + case PasswordHealth::Quality::Excellent: + qualityColor.setNamedColor("green"); + break; + } + + auto row = QList(); + row << new QStandardItem(descr); + row << new QStandardItem(entry->iconPixmap(), entry->title()); + row << new QStandardItem(group->iconPixmap(), group->hierarchy().join("/")); + row << new QStandardItem(QString::number(health->score())); + row << new QStandardItem(health->scoreReason()); + + // Set background color of first column according to password quality. + // Set the same as foreground color so the description is usually + // invisible, it's just for screen readers etc. + QBrush brush(qualityColor); + row[0]->setForeground(brush); + row[0]->setBackground(brush); + + // Set tooltips + row[0]->setToolTip(tip); + row[4]->setToolTip(health->scoreDetails()); + + // Store entry pointer per table row (used in double click handler) + m_referencesModel->appendRow(row); + m_rowToEntry.append({group, entry}); +} + +void ReportsWidgetHealthcheck::loadSettings(QSharedPointer db) +{ + m_db = std::move(db); + m_healthCalculated = false; + m_referencesModel->clear(); + m_rowToEntry.clear(); + + auto row = QList(); + row << new QStandardItem(tr("Please wait, health data is being calculated...")); + m_referencesModel->appendRow(row); +} + +void ReportsWidgetHealthcheck::showEvent(QShowEvent* event) +{ + QWidget::showEvent(event); + + if (!m_healthCalculated) { + // Perform stats calculation on next event loop to allow widget to appear + m_healthCalculated = true; + QTimer::singleShot(0, this, SLOT(calculateHealth())); + } +} + +void ReportsWidgetHealthcheck::calculateHealth() +{ + m_referencesModel->clear(); + + const QScopedPointer health(AsyncTask::runAndWaitForFuture([this] { return new Health(m_db); })); + if (health->items().empty()) { + // No findings + m_referencesModel->clear(); + m_referencesModel->setHorizontalHeaderLabels(QStringList() << tr("Congratulations, everything is healthy!")); + } else { + // Show our findings + m_referencesModel->setHorizontalHeaderLabels(QStringList() << tr("") << tr("Title") << tr("Path") << tr("Score") + << tr("Reason")); + for (const auto& item : health->items()) { + addHealthRow(item->health, item->group, item->entry); + } + } + + m_ui->healthcheckTableView->resizeRowsToContents(); +} + +void ReportsWidgetHealthcheck::emitEntryActivated(const QModelIndex& index) +{ + if (!index.isValid()) { + return; + } + + const auto row = m_rowToEntry[index.row()]; + const auto group = row.first; + const auto entry = row.second; + if (group && entry) { + emit entryActivated(group, const_cast(entry)); + } +} + +void ReportsWidgetHealthcheck::saveSettings() +{ + // nothing to do - the tab is passive +} diff --git a/src/gui/reports/ReportsWidgetHealthcheck.h b/src/gui/reports/ReportsWidgetHealthcheck.h new file mode 100644 index 0000000000..bf0cf531e4 --- /dev/null +++ b/src/gui/reports/ReportsWidgetHealthcheck.h @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2019 KeePassXC Team + * + * 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 . + */ + +#ifndef KEEPASSXC_REPORTSWIDGETHEALTHCHECK_H +#define KEEPASSXC_REPORTSWIDGETHEALTHCHECK_H + +#include "gui/entry/EntryModel.h" +#include +#include +#include +#include + +class Database; +class Entry; +class Group; +class PasswordHealth; +class QStandardItemModel; + +namespace Ui +{ + class ReportsWidgetHealthcheck; +} + +class ReportsWidgetHealthcheck : public QWidget +{ + Q_OBJECT +public: + explicit ReportsWidgetHealthcheck(QWidget* parent = nullptr); + ~ReportsWidgetHealthcheck(); + + void loadSettings(QSharedPointer db); + void saveSettings(); + +protected: + void showEvent(QShowEvent* event) override; + +signals: + void entryActivated(const Group* group, Entry* entry); + +public slots: + void calculateHealth(); + void emitEntryActivated(const QModelIndex& index); + +private: + void addHealthRow(QSharedPointer, const Group*, const Entry*); + + QScopedPointer m_ui; + + bool m_healthCalculated = false; + QIcon m_errorIcon; + QScopedPointer m_referencesModel; + QSharedPointer m_db; + QList> m_rowToEntry; +}; + +#endif // KEEPASSXC_REPORTSWIDGETHEALTHCHECK_H diff --git a/src/gui/reports/ReportsWidgetHealthcheck.ui b/src/gui/reports/ReportsWidgetHealthcheck.ui new file mode 100644 index 0000000000..48d8df07fa --- /dev/null +++ b/src/gui/reports/ReportsWidgetHealthcheck.ui @@ -0,0 +1,79 @@ + + + ReportsWidgetHealthcheck + + + + 0 + 0 + 327 + 379 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Health Check + + + + + + QAbstractItemView::NoEditTriggers + + + false + + + true + + + Qt::ElideMiddle + + + false + + + true + + + true + + + false + + + + + + + + true + + + + Hover over reason to show additional details. Double-click entries to edit. + + + + + + + + + + + diff --git a/src/gui/dbsettings/DatabaseSettingsWidgetStatistics.cpp b/src/gui/reports/ReportsWidgetStatistics.cpp similarity index 86% rename from src/gui/dbsettings/DatabaseSettingsWidgetStatistics.cpp rename to src/gui/reports/ReportsWidgetStatistics.cpp index b02741adbc..bc642af786 100644 --- a/src/gui/dbsettings/DatabaseSettingsWidgetStatistics.cpp +++ b/src/gui/reports/ReportsWidgetStatistics.cpp @@ -15,15 +15,15 @@ * along with this program. If not, see . */ -#include "DatabaseSettingsWidgetStatistics.h" -#include "ui_DatabaseSettingsWidgetStatistics.h" +#include "ReportsWidgetStatistics.h" +#include "ui_ReportsWidgetStatistics.h" #include "core/AsyncTask.h" #include "core/Database.h" #include "core/FilePath.h" #include "core/Group.h" #include "core/Metadata.h" -#include "zxcvbn.h" +#include "core/PasswordHealth.h" #include #include @@ -48,6 +48,7 @@ namespace // Ctor does all the work explicit Stats(QSharedPointer db) : modified(QFileInfo(db->filePath()).lastModified()) + , m_db(db) { gatherStats(db->rootGroup()->groupsRecursive(true)); } @@ -92,19 +93,27 @@ namespace } private: + QSharedPointer m_db; QHash m_passwords; void gatherStats(const QList& groups) { + auto checker = HealthChecker(m_db); + for (const auto* group : groups) { // Don't count anything in the recycle bin - if (group == group->database()->metadata()->recycleBin()) { + if (group->isRecycled()) { continue; } ++nGroups; for (const auto* entry : group->entries()) { + // Don't count anything in the recycle bin + if (entry->isRecycled()) { + continue; + } + ++nEntries; if (entry->isExpired()) { @@ -125,7 +134,7 @@ namespace } // Speed up Zxcvbn process by excluding very long passwords and most passphrases - if (pwd.size() < 25 && ZxcvbnMatch(pwd.toLatin1(), nullptr, nullptr) < 65) { + if (pwd.size() < 25 && checker.evaluate(entry)->quality() <= PasswordHealth::Quality::Weak) { ++nPwdsWeak; } @@ -138,9 +147,9 @@ namespace }; } // namespace -DatabaseSettingsWidgetStatistics::DatabaseSettingsWidgetStatistics(QWidget* parent) +ReportsWidgetStatistics::ReportsWidgetStatistics(QWidget* parent) : QWidget(parent) - , m_ui(new Ui::DatabaseSettingsWidgetStatistics()) + , m_ui(new Ui::ReportsWidgetStatistics()) , m_errIcon(FilePath::instance()->icon("status", "dialog-error")) { m_ui->setupUi(this); @@ -148,14 +157,15 @@ DatabaseSettingsWidgetStatistics::DatabaseSettingsWidgetStatistics(QWidget* pare m_referencesModel.reset(new QStandardItemModel()); m_referencesModel->setHorizontalHeaderLabels(QStringList() << tr("Name") << tr("Value")); m_ui->statisticsTableView->setModel(m_referencesModel.data()); + m_ui->statisticsTableView->setSelectionMode(QAbstractItemView::NoSelection); m_ui->statisticsTableView->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); } -DatabaseSettingsWidgetStatistics::~DatabaseSettingsWidgetStatistics() +ReportsWidgetStatistics::~ReportsWidgetStatistics() { } -void DatabaseSettingsWidgetStatistics::addStatsRow(QString name, QString value, bool bad, QString badMsg) +void ReportsWidgetStatistics::addStatsRow(QString name, QString value, bool bad, QString badMsg) { auto row = QList(); row << new QStandardItem(name); @@ -170,7 +180,7 @@ void DatabaseSettingsWidgetStatistics::addStatsRow(QString name, QString value, } }; -void DatabaseSettingsWidgetStatistics::loadSettings(QSharedPointer db) +void ReportsWidgetStatistics::loadSettings(QSharedPointer db) { m_db = std::move(db); m_statsCalculated = false; @@ -178,7 +188,7 @@ void DatabaseSettingsWidgetStatistics::loadSettings(QSharedPointer db) addStatsRow(tr("Please wait, database statistics are being calculated..."), ""); } -void DatabaseSettingsWidgetStatistics::showEvent(QShowEvent* event) +void ReportsWidgetStatistics::showEvent(QShowEvent* event) { QWidget::showEvent(event); @@ -189,9 +199,9 @@ void DatabaseSettingsWidgetStatistics::showEvent(QShowEvent* event) } } -void DatabaseSettingsWidgetStatistics::calculateStats() +void ReportsWidgetStatistics::calculateStats() { - const auto stats = AsyncTask::runAndWaitForFuture([this] { return new Stats(m_db); }); + const QScopedPointer stats(AsyncTask::runAndWaitForFuture([this] { return new Stats(m_db); })); m_referencesModel->clear(); addStatsRow(tr("Database name"), m_db->metadata()->name()); @@ -231,7 +241,7 @@ void DatabaseSettingsWidgetStatistics::calculateStats() tr("Average password length is less than ten characters. Longer passwords provide more security.")); } -void DatabaseSettingsWidgetStatistics::saveSettings() +void ReportsWidgetStatistics::saveSettings() { // nothing to do - the tab is passive } diff --git a/src/gui/dbsettings/DatabaseSettingsWidgetStatistics.h b/src/gui/reports/ReportsWidgetStatistics.h similarity index 74% rename from src/gui/dbsettings/DatabaseSettingsWidgetStatistics.h rename to src/gui/reports/ReportsWidgetStatistics.h index 2bd42f13d0..cc11a75f56 100644 --- a/src/gui/dbsettings/DatabaseSettingsWidgetStatistics.h +++ b/src/gui/reports/ReportsWidgetStatistics.h @@ -15,8 +15,8 @@ * along with this program. If not, see . */ -#ifndef KEEPASSXC_DATABASESETTINGSWIDGETSTATISTICS_H -#define KEEPASSXC_DATABASESETTINGSWIDGETSTATISTICS_H +#ifndef KEEPASSXC_REPORTSWIDGETSTATISTICS_H +#define KEEPASSXC_REPORTSWIDGETSTATISTICS_H #include #include @@ -26,15 +26,15 @@ class QStandardItemModel; namespace Ui { - class DatabaseSettingsWidgetStatistics; + class ReportsWidgetStatistics; } -class DatabaseSettingsWidgetStatistics : public QWidget +class ReportsWidgetStatistics : public QWidget { Q_OBJECT public: - explicit DatabaseSettingsWidgetStatistics(QWidget* parent = nullptr); - ~DatabaseSettingsWidgetStatistics(); + explicit ReportsWidgetStatistics(QWidget* parent = nullptr); + ~ReportsWidgetStatistics(); void loadSettings(QSharedPointer db); void saveSettings(); @@ -46,7 +46,7 @@ private slots: void calculateStats(); private: - QScopedPointer m_ui; + QScopedPointer m_ui; bool m_statsCalculated = false; QIcon m_errIcon; @@ -56,4 +56,4 @@ private slots: void addStatsRow(QString name, QString value, bool bad = false, QString badMsg = ""); }; -#endif // KEEPASSXC_DATABASESETTINGSWIDGETSTATISTICS_H +#endif // KEEPASSXC_REPORTSWIDGETSTATISTICS_H diff --git a/src/gui/dbsettings/DatabaseSettingsWidgetStatistics.ui b/src/gui/reports/ReportsWidgetStatistics.ui similarity index 94% rename from src/gui/dbsettings/DatabaseSettingsWidgetStatistics.ui rename to src/gui/reports/ReportsWidgetStatistics.ui index ed9d6346e6..1f3bf5fea9 100644 --- a/src/gui/dbsettings/DatabaseSettingsWidgetStatistics.ui +++ b/src/gui/reports/ReportsWidgetStatistics.ui @@ -1,7 +1,7 @@ - DatabaseSettingsWidgetStatistics - + ReportsWidgetStatistics + 0 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index fc27f48d33..c3f1c0e22b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -176,6 +176,9 @@ add_unit_test(NAME testmerge SOURCES TestMerge.cpp add_unit_test(NAME testpasswordgenerator SOURCES TestPasswordGenerator.cpp LIBS ${TEST_LIBRARIES}) +add_unit_test(NAME testpasswordhealth SOURCES TestPasswordHealth.cpp + LIBS ${TEST_LIBRARIES}) + add_unit_test(NAME testpassphrasegenerator SOURCES TestPassphraseGenerator.cpp LIBS ${TEST_LIBRARIES}) diff --git a/tests/TestPasswordHealth.cpp b/tests/TestPasswordHealth.cpp new file mode 100644 index 0000000000..238b78b924 --- /dev/null +++ b/tests/TestPasswordHealth.cpp @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2019 KeePassXC Team + * + * 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 . + */ + +#include "TestPasswordHealth.h" +#include "TestGlobal.h" + +#include "core/PasswordHealth.h" + +QTEST_GUILESS_MAIN(TestPasswordHealth) + +void TestPasswordHealth::initTestCase() +{ +} + +void TestPasswordHealth::testNoDb() +{ + const auto empty = PasswordHealth(""); + QCOMPARE(empty.score(), 0); + QCOMPARE(empty.entropy(), 0.0); + QCOMPARE(empty.quality(), PasswordHealth::Quality::Bad); + QVERIFY(!empty.scoreReason().isEmpty()); + QVERIFY(!empty.scoreDetails().isEmpty()); + + const auto poor = PasswordHealth("secret"); + QCOMPARE(poor.score(), 6); + QCOMPARE(int(poor.entropy()), 6); + QCOMPARE(poor.quality(), PasswordHealth::Quality::Poor); + QVERIFY(!poor.scoreReason().isEmpty()); + QVERIFY(!poor.scoreDetails().isEmpty()); + + const auto weak = PasswordHealth("Yohb2ChR4"); + QCOMPARE(weak.score(), 47); + QCOMPARE(int(weak.entropy()), 47); + QCOMPARE(weak.quality(), PasswordHealth::Quality::Weak); + QVERIFY(!weak.scoreReason().isEmpty()); + QVERIFY(!weak.scoreDetails().isEmpty()); + + const auto good = PasswordHealth("MIhIN9UKrgtPL2hp"); + QCOMPARE(good.score(), 78); + QCOMPARE(int(good.entropy()), 78); + QCOMPARE(good.quality(), PasswordHealth::Quality::Good); + QVERIFY(good.scoreReason().isEmpty()); + QVERIFY(good.scoreDetails().isEmpty()); + + const auto excellent = PasswordHealth("prompter-ream-oversleep-step-extortion-quarrel-reflected-prefix"); + QCOMPARE(excellent.score(), 164); + QCOMPARE(int(excellent.entropy()), 164); + QCOMPARE(excellent.quality(), PasswordHealth::Quality::Excellent); + QVERIFY(excellent.scoreReason().isEmpty()); + QVERIFY(excellent.scoreDetails().isEmpty()); +} diff --git a/tests/TestPasswordHealth.h b/tests/TestPasswordHealth.h new file mode 100644 index 0000000000..2d887a7de3 --- /dev/null +++ b/tests/TestPasswordHealth.h @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2019 KeePassXC Team + * + * 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 . + */ + +#ifndef KEEPASSX_TESTPASSWORDHEALTH_H +#define KEEPASSX_TESTPASSWORDHEALTH_H + +#include + +class TestPasswordHealth : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + void testNoDb(); +}; + +#endif // KEEPASSX_TESTPASSWORDHEALTH_H diff --git a/utils/makeicons.sh b/utils/makeicons.sh index 6efc608eed..887874161b 100644 --- a/utils/makeicons.sh +++ b/utils/makeicons.sh @@ -99,6 +99,7 @@ map() { group-edit) echo folder-edit-outline ;; group-empty-trash) echo trash-can-outline ;; group-new) echo folder-plus-outline ;; + health) echo heart-pulse ;; help-about) echo information-outline ;; internet-web-browser) echo web ;; key-enter) echo keyboard-variant ;; From ad236281c6e48b24eb3ad52756dcf90de376a711 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfram=20R=C3=B6sler?= Date: Thu, 30 Jan 2020 21:09:29 +0100 Subject: [PATCH 2/2] Remove result cache from the HealthChecker class The way the class is currently being used, the cache never does anything (because evaluate is never invoked twice for the same entry), so according to YAGNI it has to go. Fixes #551 --- src/core/PasswordHealth.cpp | 10 +++------- src/core/PasswordHealth.h | 6 ++---- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/core/PasswordHealth.cpp b/src/core/PasswordHealth.cpp index 58e4e42af5..c179db77ca 100644 --- a/src/core/PasswordHealth.cpp +++ b/src/core/PasswordHealth.cpp @@ -116,17 +116,13 @@ HealthChecker::HealthChecker(QSharedPointer db) * Returns the health of the password in `entry`, considering * password entropy, re-use, expiration, etc. */ -QSharedPointer HealthChecker::evaluate(const Entry* entry) +QSharedPointer HealthChecker::evaluate(const Entry* entry) const { + // Pointer sanity check if (!entry) { return {}; } - // Return from cache if we saw it before - if (m_cache.contains(entry->uuid())) { - return m_cache[entry->uuid()]; - } - // First analyse the password itself const auto pwd = entry->password(); auto health = QSharedPointer(new PasswordHealth(pwd)); @@ -184,5 +180,5 @@ QSharedPointer HealthChecker::evaluate(const Entry* entry) } // Return the result - return m_cache.insert(entry->uuid(), health).value(); + return health; } diff --git a/src/core/PasswordHealth.h b/src/core/PasswordHealth.h index ca7f0236ec..70f83eee70 100644 --- a/src/core/PasswordHealth.h +++ b/src/core/PasswordHealth.h @@ -101,12 +101,10 @@ class HealthChecker explicit HealthChecker(QSharedPointer); // Get the health status of an entry in the database - QSharedPointer evaluate(const Entry* entry); + QSharedPointer evaluate(const Entry* entry) const; private: - // Result cache (first=entry UUID) - QHash> m_cache; - // first = password, second = entries that use it + // To determine password re-use: first = password, second = entries that use it QHash m_reuse; };