diff --git a/src/cli/Clip.cpp b/src/cli/Clip.cpp index a9135eff4..2cc15411b 100644 --- a/src/cli/Clip.cpp +++ b/src/cli/Clip.cpp @@ -51,6 +51,9 @@ int Clip::execute(const QStringList& arguments) QObject::tr("Key file of the database."), QObject::tr("path")); parser.addOption(keyFile); + QCommandLineOption totp(QStringList() << "t" << "totp", + QObject::tr("Copy the current TOTP to the clipboard.")); + parser.addOption(totp); parser.addPositionalArgument("entry", QObject::tr("Path of the entry to clip.", "clip = copy to clipboard")); parser.addPositionalArgument("timeout", QObject::tr("Timeout in seconds before clearing the clipboard."), "[timeout]"); @@ -68,10 +71,10 @@ int Clip::execute(const QStringList& arguments) return EXIT_FAILURE; } - return clipEntry(db, args.at(1), args.value(2)); + return clipEntry(db, args.at(1), args.value(2), parser.isSet(totp)); } -int Clip::clipEntry(Database* database, const QString& entryPath, const QString& timeout) +int Clip::clipEntry(Database* database, const QString& entryPath, const QString& timeout, bool clipTotp) { TextStream err(Utils::STDERR); @@ -90,12 +93,28 @@ int Clip::clipEntry(Database* database, const QString& entryPath, const QString& return EXIT_FAILURE; } - int exitCode = Utils::clipText(entry->password()); + QString value; + if (clipTotp) { + if (!entry->hasTotp()) { + err << QObject::tr("Entry with path %1 has no TOTP set up.").arg(entryPath) << endl; + return EXIT_FAILURE; + } + + value = entry->totp(); + } else { + value = entry->password(); + } + + int exitCode = Utils::clipText(value); if (exitCode != EXIT_SUCCESS) { return exitCode; } - outputTextStream << QObject::tr("Entry's password copied to the clipboard!") << endl; + if (clipTotp) { + outputTextStream << QObject::tr("Entry's current TOTP copied to the clipboard!") << endl; + } else { + outputTextStream << QObject::tr("Entry's password copied to the clipboard!") << endl; + } if (!timeoutSeconds) { return exitCode; diff --git a/src/cli/Clip.h b/src/cli/Clip.h index e5e6390ae..9f7151322 100644 --- a/src/cli/Clip.h +++ b/src/cli/Clip.h @@ -26,7 +26,7 @@ public: Clip(); ~Clip(); int execute(const QStringList& arguments) override; - int clipEntry(Database* database, const QString& entryPath, const QString& timeout); + int clipEntry(Database* database, const QString& entryPath, const QString& timeout, bool clipTotp); }; #endif // KEEPASSXC_CLIP_H diff --git a/src/cli/Show.cpp b/src/cli/Show.cpp index e474e2489..7b42de7ab 100644 --- a/src/cli/Show.cpp +++ b/src/cli/Show.cpp @@ -50,6 +50,9 @@ int Show::execute(const QStringList& arguments) QObject::tr("Key file of the database."), QObject::tr("path")); parser.addOption(keyFile); + QCommandLineOption totp(QStringList() << "t" << "totp", + QObject::tr("Show the entry's current TOTP.")); + parser.addOption(totp); QCommandLineOption attributes( QStringList() << "a" << "attributes", QObject::tr( @@ -73,10 +76,10 @@ int Show::execute(const QStringList& arguments) return EXIT_FAILURE; } - return showEntry(db.data(), parser.values(attributes), args.at(1)); + return showEntry(db.data(), parser.values(attributes), parser.isSet(totp), args.at(1)); } -int Show::showEntry(Database* database, QStringList attributes, const QString& entryPath) +int Show::showEntry(Database* database, QStringList attributes, bool showTotp, const QString& entryPath) { TextStream in(Utils::STDIN, QIODevice::ReadOnly); TextStream out(Utils::STDOUT, QIODevice::WriteOnly); @@ -88,9 +91,14 @@ int Show::showEntry(Database* database, QStringList attributes, const QString& e return EXIT_FAILURE; } + if (showTotp && !entry->hasTotp()) { + err << QObject::tr("Entry with path %1 has no TOTP set up.").arg(entryPath) << endl; + return EXIT_FAILURE; + } + // If no attributes specified, output the default attribute set. - bool showAttributeNames = attributes.isEmpty(); - if (attributes.isEmpty()) { + bool showAttributeNames = attributes.isEmpty() && !showTotp; + if (attributes.isEmpty() && !showTotp) { attributes = EntryAttributes::DefaultAttributes; } @@ -107,5 +115,13 @@ int Show::showEntry(Database* database, QStringList attributes, const QString& e } out << entry->resolveMultiplePlaceholders(entry->attributes()->value(attribute)) << endl; } + + if (showTotp) { + if (showAttributeNames) { + out << "TOTP: "; + } + out << entry->totp() << endl; + } + return sawUnknownAttribute ? EXIT_FAILURE : EXIT_SUCCESS; } diff --git a/src/cli/Show.h b/src/cli/Show.h index 6d49d1207..32dab7efb 100644 --- a/src/cli/Show.h +++ b/src/cli/Show.h @@ -26,7 +26,7 @@ public: Show(); ~Show(); int execute(const QStringList& arguments) override; - int showEntry(Database* database, QStringList attributes, const QString& entryPath); + int showEntry(Database* database, QStringList attributes, bool showTotp, const QString& entryPath); }; #endif // KEEPASSXC_SHOW_H diff --git a/src/cli/keepassxc-cli.1 b/src/cli/keepassxc-cli.1 index 877346646..0d618c9d1 100644 --- a/src/cli/keepassxc-cli.1 +++ b/src/cli/keepassxc-cli.1 @@ -17,7 +17,7 @@ keepassxc-cli \- command line interface for the \fBKeePassXC\fP password manager Adds a new entry to a database. A password can be generated (\fI-g\fP option), or a prompt can be displayed to input the password (\fI-p\fP option). .IP "clip [options] [timeout]" -Copies the password of a database entry to the clipboard. If multiple entries with the same name exist in different groups, only the password for the first one is going to be copied. For copying the password of an entry in a specific group, the group path to the entry should be specified as well, instead of just the name. Optionally, a timeout in seconds can be specified to automatically clear the clipboard. +Copies the password or the current TOTP (\fI-t\fP option) of a database entry to the clipboard. If multiple entries with the same name exist in different groups, only the password for the first one is going to be copied. For copying the password of an entry in a specific group, the group path to the entry should be specified as well, instead of just the name. Optionally, a timeout in seconds can be specified to automatically clear the clipboard. .IP "diceware [options]" Generate a random diceware passphrase. @@ -47,7 +47,7 @@ Merges two databases together. The first database file is going to be replaced b Removes an entry from a database. If the database has a recycle bin, the entry will be moved there. If the entry is already in the recycle bin, it will be removed permanently. .IP "show [options] " -Shows the title, username, password, URL and notes of a database entry. Regarding the occurrence of multiple entries with the same name in different groups, everything stated in the \fIclip\fP command section also applies here. +Shows the title, username, password, URL and notes of a database entry. Can also show the current TOTP. Regarding the occurrence of multiple entries with the same name in different groups, everything stated in the \fIclip\fP command section also applies here. .SH OPTIONS @@ -102,12 +102,23 @@ Specify the title of the entry. Perform advanced analysis on the password. +.SS "Clip options" + +.IP "-t, --totp" +Copy the current TOTP instead of current password to clipboard. Will report an error +if no TOTP is configured for the entry. + + .SS "Show options" .IP "-a, --attributes ..." Names of the attributes to show. This option can be specified more than once, with each attribute shown one-per-line in the given order. If no attributes are -specified, a summary of the default attributes is given. +specified and \fI-t\fP is not specified, a summary of the default attributes is given. + +.IP "-t, --totp" +Also show the current TOTP. Will report an error if no TOTP is configured for the +entry. .SS "Diceware options" diff --git a/tests/TestCli.cpp b/tests/TestCli.cpp index c95b1f32b..b1c3a82e8 100644 --- a/tests/TestCli.cpp +++ b/tests/TestCli.cpp @@ -67,6 +67,12 @@ void TestCli::initTestCase() QVERIFY(sourceDbFile.open(QIODevice::ReadOnly)); QVERIFY(Tools::readAllFromDevice(&sourceDbFile, m_dbData)); sourceDbFile.close(); + + // Load the NewDatabase.kdbx file into temporary storage + QFile sourceDbFile2(QString(KEEPASSX_TEST_DATA_DIR).append("/NewDatabase2.kdbx")); + QVERIFY(sourceDbFile2.open(QIODevice::ReadOnly)); + QVERIFY(Tools::readAllFromDevice(&sourceDbFile2, m_dbData2)); + sourceDbFile2.close(); } void TestCli::init() @@ -76,6 +82,11 @@ void TestCli::init() m_dbFile->write(m_dbData); m_dbFile->close(); + m_dbFile2.reset(new TemporaryFile()); + m_dbFile2->open(); + m_dbFile2->write(m_dbData2); + m_dbFile2->close(); + m_stdinFile.reset(new TemporaryFile()); m_stdinFile->open(); m_stdinHandle = fdopen(m_stdinFile->handle(), "r+"); @@ -96,6 +107,8 @@ void TestCli::cleanup() { m_dbFile.reset(); + m_dbFile2.reset(); + m_stdinFile.reset(); m_stdinHandle = stdin; Utils::STDIN = stdin; @@ -168,6 +181,19 @@ void TestCli::testAdd() QCOMPARE(entry->password(), QString("newpassword")); } +bool isTOTP(const QString & value) { + QString val = value.trimmed(); + if (val.length() < 5 || val.length() > 6) { + return false; + } + for (int i = 0; i < val.length(); ++i) { + if (!value[i].isDigit()) { + return false; + } + } + return true; +} + void TestCli::testClip() { QClipboard* clipboard = QGuiApplication::clipboard(); @@ -177,6 +203,7 @@ void TestCli::testClip() QVERIFY(!clipCmd.name.isEmpty()); QVERIFY(clipCmd.getDescriptionLine().contains(clipCmd.name)); + // Password Utils::Test::setNextPassword("a"); clipCmd.execute({"clip", m_dbFile->fileName(), "/Sample Entry"}); @@ -190,6 +217,13 @@ void TestCli::testClip() QCOMPARE(clipboard->text(), QString("Password")); + // TOTP + Utils::Test::setNextPassword("a"); + clipCmd.execute({"clip", m_dbFile->fileName(), "/Sample Entry", "--totp"}); + + QVERIFY(isTOTP(clipboard->text())); + + // Password with timeout Utils::Test::setNextPassword("a"); QFuture future = QtConcurrent::run(&clipCmd, &Clip::execute, QStringList{"clip", m_dbFile->fileName(), "/Sample Entry", "1"}); @@ -197,6 +231,21 @@ void TestCli::testClip() QTRY_COMPARE_WITH_TIMEOUT(clipboard->text(), QString(""), 1500); future.waitForFinished(); + + // TOTP with timeout + Utils::Test::setNextPassword("a"); + future = QtConcurrent::run(&clipCmd, &Clip::execute, QStringList{"clip", m_dbFile->fileName(), "/Sample Entry", "1", "-t"}); + + QTRY_VERIFY_WITH_TIMEOUT(isTOTP(clipboard->text()), 500); + QTRY_COMPARE_WITH_TIMEOUT(clipboard->text(), QString(""), 1500); + + future.waitForFinished(); + + qint64 posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + clipCmd.execute({"clip", m_dbFile2->fileName(), "--totp", "/Sample Entry"}); + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Entry with path /Sample Entry has no TOTP set up.\n")); } void TestCli::testDiceware() @@ -756,4 +805,29 @@ void TestCli::testShow() m_stderrFile->reset(); QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); QCOMPARE(m_stderrFile->readAll(), QByteArray("ERROR: unknown attribute DoesNotExist.\n")); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + showCmd.execute({"show", "-t", m_dbFile->fileName(), "/Sample Entry"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + QVERIFY(isTOTP(m_stdoutFile->readAll())); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + showCmd.execute({"show", "-a", "Title", m_dbFile->fileName(), "--totp", "/Sample Entry"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readLine(), QByteArray("Sample Entry\n")); + QVERIFY(isTOTP(m_stdoutFile->readAll())); + + pos = m_stdoutFile->pos(); + qint64 posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + showCmd.execute({"show", m_dbFile2->fileName(), "--totp", "/Sample Entry"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Entry with path /Sample Entry has no TOTP set up.\n")); } diff --git a/tests/TestCli.h b/tests/TestCli.h index 691269840..2c411f2ca 100644 --- a/tests/TestCli.h +++ b/tests/TestCli.h @@ -58,7 +58,9 @@ private slots: private: QByteArray m_dbData; + QByteArray m_dbData2; QScopedPointer m_dbFile; + QScopedPointer m_dbFile2; QScopedPointer m_stdoutFile; QScopedPointer m_stderrFile; QScopedPointer m_stdinFile; diff --git a/tests/data/NewDatabase.kdbx b/tests/data/NewDatabase.kdbx index 4e77724c7..a8dfb5bd5 100644 Binary files a/tests/data/NewDatabase.kdbx and b/tests/data/NewDatabase.kdbx differ diff --git a/tests/data/NewDatabase2.kdbx b/tests/data/NewDatabase2.kdbx new file mode 100644 index 000000000..4e77724c7 Binary files /dev/null and b/tests/data/NewDatabase2.kdbx differ diff --git a/tests/gui/TestGui.cpp b/tests/gui/TestGui.cpp index 450f09474..8613d184e 100644 --- a/tests/gui/TestGui.cpp +++ b/tests/gui/TestGui.cpp @@ -405,10 +405,9 @@ void TestGui::testTabs() void TestGui::testEditEntry() { auto* toolBar = m_mainWindow->findChild("toolBar"); - int editCount = 0; + auto* entryView = m_dbWidget->findChild("entryView"); // Select the first entry in the database - auto* entryView = m_dbWidget->findChild("entryView"); QModelIndex entryItem = entryView->model()->index(0, 1); Entry* entry = entryView->entryFromIndex(entryItem); clickIndex(entryItem, entryView, Qt::LeftButton); @@ -420,6 +419,9 @@ void TestGui::testEditEntry() QVERIFY(entryEditWidget->isVisible()); QVERIFY(entryEditWidget->isEnabled()); + // Record current history count + int editCount = entry->historyItems().size(); + // Edit the first entry ("Sample Entry") QTest::mouseClick(entryEditWidget, Qt::LeftButton); QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::EditMode); @@ -748,6 +750,7 @@ void TestGui::testTotp() QApplication::processEvents(); auto* seedEdit = setupTotpDialog->findChild("seedEdit"); + seedEdit->setText(""); QString exampleSeed = "gezdgnbvgy3tqojqgezdgnbvgy3tqojq"; QTest::keyClicks(seedEdit, exampleSeed);