CLI: add commands to show and copy TOTP to clipboard (#2454)

* Add CLI commands show --totp and totp-clip for handling TOTPs, resolves #2429.
* Adding tests for new CLI TOTP commands
* Update keepassxc-cli man page.
This commit is contained in:
Felix Fontein 2018-11-10 03:58:42 +01:00 committed by Jonathan White
parent 91bccf75d5
commit a7dd9f19f4
10 changed files with 140 additions and 15 deletions

View File

@ -51,6 +51,9 @@ int Clip::execute(const QStringList& arguments)
QObject::tr("Key file of the database."), QObject::tr("Key file of the database."),
QObject::tr("path")); QObject::tr("path"));
parser.addOption(keyFile); 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("entry", QObject::tr("Path of the entry to clip.", "clip = copy to clipboard"));
parser.addPositionalArgument("timeout", parser.addPositionalArgument("timeout",
QObject::tr("Timeout in seconds before clearing the clipboard."), "[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 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); TextStream err(Utils::STDERR);
@ -90,12 +93,28 @@ int Clip::clipEntry(Database* database, const QString& entryPath, const QString&
return EXIT_FAILURE; 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) { if (exitCode != EXIT_SUCCESS) {
return exitCode; 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) { if (!timeoutSeconds) {
return exitCode; return exitCode;

View File

@ -26,7 +26,7 @@ public:
Clip(); Clip();
~Clip(); ~Clip();
int execute(const QStringList& arguments) override; 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 #endif // KEEPASSXC_CLIP_H

View File

@ -50,6 +50,9 @@ int Show::execute(const QStringList& arguments)
QObject::tr("Key file of the database."), QObject::tr("Key file of the database."),
QObject::tr("path")); QObject::tr("path"));
parser.addOption(keyFile); parser.addOption(keyFile);
QCommandLineOption totp(QStringList() << "t" << "totp",
QObject::tr("Show the entry's current TOTP."));
parser.addOption(totp);
QCommandLineOption attributes( QCommandLineOption attributes(
QStringList() << "a" << "attributes", QStringList() << "a" << "attributes",
QObject::tr( QObject::tr(
@ -73,10 +76,10 @@ int Show::execute(const QStringList& arguments)
return EXIT_FAILURE; 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 in(Utils::STDIN, QIODevice::ReadOnly);
TextStream out(Utils::STDOUT, QIODevice::WriteOnly); TextStream out(Utils::STDOUT, QIODevice::WriteOnly);
@ -88,9 +91,14 @@ int Show::showEntry(Database* database, QStringList attributes, const QString& e
return EXIT_FAILURE; 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. // If no attributes specified, output the default attribute set.
bool showAttributeNames = attributes.isEmpty(); bool showAttributeNames = attributes.isEmpty() && !showTotp;
if (attributes.isEmpty()) { if (attributes.isEmpty() && !showTotp) {
attributes = EntryAttributes::DefaultAttributes; 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; out << entry->resolveMultiplePlaceholders(entry->attributes()->value(attribute)) << endl;
} }
if (showTotp) {
if (showAttributeNames) {
out << "TOTP: ";
}
out << entry->totp() << endl;
}
return sawUnknownAttribute ? EXIT_FAILURE : EXIT_SUCCESS; return sawUnknownAttribute ? EXIT_FAILURE : EXIT_SUCCESS;
} }

View File

@ -26,7 +26,7 @@ public:
Show(); Show();
~Show(); ~Show();
int execute(const QStringList& arguments) override; 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 #endif // KEEPASSXC_SHOW_H

View File

@ -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). 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] <database> <entry> [timeout]" .IP "clip [options] <database> <entry> [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]" .IP "diceware [options]"
Generate a random diceware passphrase. 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. 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] <database> <entry>" .IP "show [options] <database> <entry>"
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 .SH OPTIONS
@ -102,12 +102,23 @@ Specify the title of the entry.
Perform advanced analysis on the password. 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" .SS "Show options"
.IP "-a, --attributes <attribute>..." .IP "-a, --attributes <attribute>..."
Names of the attributes to show. This option can be specified more than once, 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 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" .SS "Diceware options"

View File

@ -67,6 +67,12 @@ void TestCli::initTestCase()
QVERIFY(sourceDbFile.open(QIODevice::ReadOnly)); QVERIFY(sourceDbFile.open(QIODevice::ReadOnly));
QVERIFY(Tools::readAllFromDevice(&sourceDbFile, m_dbData)); QVERIFY(Tools::readAllFromDevice(&sourceDbFile, m_dbData));
sourceDbFile.close(); 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() void TestCli::init()
@ -76,6 +82,11 @@ void TestCli::init()
m_dbFile->write(m_dbData); m_dbFile->write(m_dbData);
m_dbFile->close(); 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.reset(new TemporaryFile());
m_stdinFile->open(); m_stdinFile->open();
m_stdinHandle = fdopen(m_stdinFile->handle(), "r+"); m_stdinHandle = fdopen(m_stdinFile->handle(), "r+");
@ -96,6 +107,8 @@ void TestCli::cleanup()
{ {
m_dbFile.reset(); m_dbFile.reset();
m_dbFile2.reset();
m_stdinFile.reset(); m_stdinFile.reset();
m_stdinHandle = stdin; m_stdinHandle = stdin;
Utils::STDIN = stdin; Utils::STDIN = stdin;
@ -168,6 +181,19 @@ void TestCli::testAdd()
QCOMPARE(entry->password(), QString("newpassword")); 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() void TestCli::testClip()
{ {
QClipboard* clipboard = QGuiApplication::clipboard(); QClipboard* clipboard = QGuiApplication::clipboard();
@ -177,6 +203,7 @@ void TestCli::testClip()
QVERIFY(!clipCmd.name.isEmpty()); QVERIFY(!clipCmd.name.isEmpty());
QVERIFY(clipCmd.getDescriptionLine().contains(clipCmd.name)); QVERIFY(clipCmd.getDescriptionLine().contains(clipCmd.name));
// Password
Utils::Test::setNextPassword("a"); Utils::Test::setNextPassword("a");
clipCmd.execute({"clip", m_dbFile->fileName(), "/Sample Entry"}); clipCmd.execute({"clip", m_dbFile->fileName(), "/Sample Entry"});
@ -190,6 +217,13 @@ void TestCli::testClip()
QCOMPARE(clipboard->text(), QString("Password")); 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"); Utils::Test::setNextPassword("a");
QFuture<void> future = QtConcurrent::run(&clipCmd, &Clip::execute, QStringList{"clip", m_dbFile->fileName(), "/Sample Entry", "1"}); QFuture<void> 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); QTRY_COMPARE_WITH_TIMEOUT(clipboard->text(), QString(""), 1500);
future.waitForFinished(); 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() void TestCli::testDiceware()
@ -756,4 +805,29 @@ void TestCli::testShow()
m_stderrFile->reset(); m_stderrFile->reset();
QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); QCOMPARE(m_stdoutFile->readAll(), QByteArray(""));
QCOMPARE(m_stderrFile->readAll(), QByteArray("ERROR: unknown attribute DoesNotExist.\n")); 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"));
} }

View File

@ -58,7 +58,9 @@ private slots:
private: private:
QByteArray m_dbData; QByteArray m_dbData;
QByteArray m_dbData2;
QScopedPointer<TemporaryFile> m_dbFile; QScopedPointer<TemporaryFile> m_dbFile;
QScopedPointer<TemporaryFile> m_dbFile2;
QScopedPointer<TemporaryFile> m_stdoutFile; QScopedPointer<TemporaryFile> m_stdoutFile;
QScopedPointer<TemporaryFile> m_stderrFile; QScopedPointer<TemporaryFile> m_stderrFile;
QScopedPointer<TemporaryFile> m_stdinFile; QScopedPointer<TemporaryFile> m_stdinFile;

Binary file not shown.

Binary file not shown.

View File

@ -405,10 +405,9 @@ void TestGui::testTabs()
void TestGui::testEditEntry() void TestGui::testEditEntry()
{ {
auto* toolBar = m_mainWindow->findChild<QToolBar*>("toolBar"); auto* toolBar = m_mainWindow->findChild<QToolBar*>("toolBar");
int editCount = 0; auto* entryView = m_dbWidget->findChild<EntryView*>("entryView");
// Select the first entry in the database // Select the first entry in the database
auto* entryView = m_dbWidget->findChild<EntryView*>("entryView");
QModelIndex entryItem = entryView->model()->index(0, 1); QModelIndex entryItem = entryView->model()->index(0, 1);
Entry* entry = entryView->entryFromIndex(entryItem); Entry* entry = entryView->entryFromIndex(entryItem);
clickIndex(entryItem, entryView, Qt::LeftButton); clickIndex(entryItem, entryView, Qt::LeftButton);
@ -420,6 +419,9 @@ void TestGui::testEditEntry()
QVERIFY(entryEditWidget->isVisible()); QVERIFY(entryEditWidget->isVisible());
QVERIFY(entryEditWidget->isEnabled()); QVERIFY(entryEditWidget->isEnabled());
// Record current history count
int editCount = entry->historyItems().size();
// Edit the first entry ("Sample Entry") // Edit the first entry ("Sample Entry")
QTest::mouseClick(entryEditWidget, Qt::LeftButton); QTest::mouseClick(entryEditWidget, Qt::LeftButton);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::EditMode); QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::EditMode);
@ -748,6 +750,7 @@ void TestGui::testTotp()
QApplication::processEvents(); QApplication::processEvents();
auto* seedEdit = setupTotpDialog->findChild<QLineEdit*>("seedEdit"); auto* seedEdit = setupTotpDialog->findChild<QLineEdit*>("seedEdit");
seedEdit->setText("");
QString exampleSeed = "gezdgnbvgy3tqojqgezdgnbvgy3tqojq"; QString exampleSeed = "gezdgnbvgy3tqojqgezdgnbvgy3tqojq";
QTest::keyClicks(seedEdit, exampleSeed); QTest::keyClicks(seedEdit, exampleSeed);