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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 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 000000000..4cd5fa091 --- /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 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 ;;