From a81c6469a87783abdd4434b03f616465c830b57f 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] 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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 V)e 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

nc~jH$Gvk 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 \ No newline at end of file diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index af9b9bb58..6b3d9abfa 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 9cb4e0735..b49af7005 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 a84e23963..3b7509057 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 e203af672..ff271a453 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 22627d25b..55418b4ba 100644 --- a/src/core/PasswordGenerator.h +++ b/src/core/PasswordGenerator.h @@ -57,7 +57,6 @@ public: 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 000000000..58e4e42af --- /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 000000000..ca7f0236e --- /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 4b9fe5f85..bd24cf165 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 c37e6c5ea..7e158406b 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 5c55bc63c..29019a2d2 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 eb33c09c0..fd579b04a 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 9f0c5c976..6420a3b24 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: 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 e9c150dd5..2d52331ff 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 e09c91dd7..aec0efb37 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 e0f8fbe5f..c04487c0e 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 b39a2f10f..eba7f815f 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 33c4df2c4..e0e6765a4 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 000000000..22ebab41a --- /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 000000000..7a53623c3 --- /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 000000000..773981a10 --- /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 000000000..41fa40625 --- /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 000000000..8a85b2d20 --- /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 6fe24ff0f..e4570e172 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 c890f3b81..00d611ee3 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 @@ public: 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 000000000..184434116 --- /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 000000000..631490405 --- /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 000000000..c668b3495 --- /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 000000000..bf0cf531e --- /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 000000000..48d8df07f --- /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 b02741adb..bc642af78 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 2bd42f13d..cc11a75f5 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: 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 ed9d6346e..1f3bf5fea 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 fc27f48d3..c3f1c0e22 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 000000000..238b78b92 --- /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 000000000..2d887a7de --- /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 6efc608ee..887874161 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 ;;