Don't add space to invalid TOTP strings

* Fixes #11357
* Introduces validity parameter to TOTP generator function for future use elsewhere in the code base
* Fixes this in preview panel and TOTP dialog
* Disable actions to copy/show TOTP if the settings are invalid
* Show an error message on the TOTP setup dialog if the settings are invalid
* Show a TOTP icon with an x if the settings are invalid
This commit is contained in:
Jonathan White
2025-05-17 17:08:41 -04:00
parent b5f4e98925
commit f62ea95499
20 changed files with 192 additions and 82 deletions

View File

@@ -223,6 +223,7 @@ Files: share/icons/application/scalable/actions/application-exit.svg
share/icons/application/scalable/actions/totp-copy.svg share/icons/application/scalable/actions/totp-copy.svg
share/icons/application/scalable/actions/totp-copy-password.svg share/icons/application/scalable/actions/totp-copy-password.svg
share/icons/application/scalable/actions/totp-edit.svg share/icons/application/scalable/actions/totp-edit.svg
share/icons/application/scalable/actions/totp-invalid.svg
share/icons/application/scalable/actions/trash.svg share/icons/application/scalable/actions/trash.svg
share/icons/application/scalable/actions/url-copy.svg share/icons/application/scalable/actions/url-copy.svg
share/icons/application/scalable/actions/user-guide.svg share/icons/application/scalable/actions/user-guide.svg

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14.47 15.08L11 13V7H12.5V12.25L15.58 14.08C15.17 14.36 14.79 14.7 14.47 15.08M13.08 19.92C12.72 19.97 12.37 20 12 20C7.58 20 4 16.42 4 12S7.58 4 12 4 20 7.58 20 12C20 12.37 19.97 12.72 19.92 13.08C20.61 13.18 21.25 13.4 21.84 13.72C21.94 13.16 22 12.59 22 12C22 6.5 17.5 2 12 2S2 6.5 2 12C2 17.5 6.47 22 12 22C12.59 22 13.16 21.94 13.72 21.84C13.4 21.25 13.18 20.61 13.08 19.92M21.12 15.46L19 17.59L16.88 15.47L15.47 16.88L17.59 19L15.47 21.12L16.88 22.54L19 20.41L21.12 22.54L22.54 21.12L20.41 19L22.54 16.88L21.12 15.46Z" /></svg>

After

Width:  |  Height:  |  Size: 602 B

View File

@@ -92,6 +92,7 @@
<file>application/scalable/actions/totp-copy.svg</file> <file>application/scalable/actions/totp-copy.svg</file>
<file>application/scalable/actions/totp-copy-password.svg</file> <file>application/scalable/actions/totp-copy-password.svg</file>
<file>application/scalable/actions/totp-edit.svg</file> <file>application/scalable/actions/totp-edit.svg</file>
<file>application/scalable/actions/totp-invalid.svg</file>
<file>application/scalable/actions/trash.svg</file> <file>application/scalable/actions/trash.svg</file>
<file>application/scalable/actions/url-copy.svg</file> <file>application/scalable/actions/url-copy.svg</file>
<file>application/scalable/actions/user-guide.svg</file> <file>application/scalable/actions/user-guide.svg</file>

View File

@@ -724,6 +724,10 @@
<source>Invalid placeholder: %1</source> <source>Invalid placeholder: %1</source>
<translation type="unfinished"></translation> <translation type="unfinished"></translation>
</message> </message>
<message>
<source>Entry has invalid TOTP settings</source>
<translation type="unfinished"></translation>
</message>
</context> </context>
<context> <context>
<name>AutoTypeAssociationsModel</name> <name>AutoTypeAssociationsModel</name>
@@ -9247,6 +9251,16 @@ This option is deprecated, use --set-key-file instead.</source>
<source>Warning: the chosen wordlist is smaller than the minimum recommended size!</source> <source>Warning: the chosen wordlist is smaller than the minimum recommended size!</source>
<translation type="unfinished"></translation> <translation type="unfinished"></translation>
</message> </message>
<message>
<source>Invalid Step</source>
<comment>TOTP</comment>
<translation type="unfinished"></translation>
</message>
<message>
<source>Invalid Digits</source>
<comment>TOTP</comment>
<translation type="unfinished"></translation>
</message>
<message> <message>
<source>Fit</source> <source>Fit</source>
<translation type="unfinished"></translation> <translation type="unfinished"></translation>
@@ -10317,6 +10331,10 @@ Example: JBSWY3DPEHPK3PXP</source>
<source>Are you sure you want to delete TOTP settings for this entry?</source> <source>Are you sure you want to delete TOTP settings for this entry?</source>
<translation type="unfinished"></translation> <translation type="unfinished"></translation>
</message> </message>
<message>
<source>Error: secret key is invalid</source>
<translation type="unfinished"></translation>
</message>
</context> </context>
<context> <context>
<name>URLEdit</name> <name>URLEdit</name>

View File

@@ -637,11 +637,17 @@ AutoType::parseSequence(const QString& entrySequence, const Entry* entry, QStrin
// Platform-specific field clearing // Platform-specific field clearing
actions << QSharedPointer<AutoTypeClearField>::create(); actions << QSharedPointer<AutoTypeClearField>::create();
} else if (placeholder == "totp") { } else if (placeholder == "totp") {
if (entry->hasValidTotp()) {
// Entry totp (requires special handling) // Entry totp (requires special handling)
QString totp = entry->totp(); QString totp = entry->totp();
for (const auto& ch : totp) { for (const auto& ch : totp) {
actions << QSharedPointer<AutoTypeKey>::create(ch); actions << QSharedPointer<AutoTypeKey>::create(ch);
} }
} else if (entry->hasTotp()) {
// Entry has TOTP configured but invalid settings
error = tr("Entry has invalid TOTP settings");
return {};
}
} else if (placeholder.startsWith("pickchars")) { } else if (placeholder.startsWith("pickchars")) {
// Reset to the original capture to preserve case // Reset to the original capture to preserve case
placeholder = match.captured(3); placeholder = match.captured(3);

View File

@@ -264,7 +264,7 @@ void AutoTypeSelectDialog::updateActionMenu(const AutoTypeMatch& match)
bool hasUsername = !match.first->username().isEmpty(); bool hasUsername = !match.first->username().isEmpty();
bool hasPassword = !match.first->password().isEmpty(); bool hasPassword = !match.first->password().isEmpty();
bool hasTotp = match.first->hasTotp(); bool hasTotp = match.first->hasValidTotp();
for (auto action : m_actionMenu->actions()) { for (auto action : m_actionMenu->actions()) {
auto prop = action->property(MENU_FIELD_PROP_NAME); auto prop = action->property(MENU_FIELD_PROP_NAME);

View File

@@ -1192,7 +1192,7 @@ QJsonObject BrowserService::prepareEntry(const Entry* entry)
res["uuid"] = entry->resolveMultiplePlaceholders(entry->uuidToHex()); res["uuid"] = entry->resolveMultiplePlaceholders(entry->uuidToHex());
res["group"] = entry->resolveMultiplePlaceholders(entry->group()->name()); res["group"] = entry->resolveMultiplePlaceholders(entry->group()->name());
if (entry->hasTotp()) { if (entry->hasValidTotp()) {
res["totp"] = entry->totp(); res["totp"] = entry->totp();
} }

View File

@@ -570,6 +570,12 @@ bool Entry::hasTotp() const
return !m_data.totpSettings.isNull(); return !m_data.totpSettings.isNull();
} }
bool Entry::hasValidTotp() const
{
auto error = Totp::checkValidSettings(m_data.totpSettings);
return error.isEmpty();
}
bool Entry::hasPasskey() const bool Entry::hasPasskey() const
{ {
return m_attributes->hasPasskey(); return m_attributes->hasPasskey();
@@ -581,10 +587,13 @@ void Entry::removePasskey()
removeTag(tr("Passkey")); removeTag(tr("Passkey"));
} }
QString Entry::totp() const QString Entry::totp(bool* isValid) const
{ {
if (hasTotp()) { if (hasTotp()) {
return Totp::generateTotp(m_data.totpSettings); return Totp::generateTotp(m_data.totpSettings, isValid);
}
if (isValid) {
*isValid = false;
} }
return {}; return {};
} }

View File

@@ -109,7 +109,7 @@ public:
QString password() const; QString password() const;
QString notes() const; QString notes() const;
QString attribute(const QString& key) const; QString attribute(const QString& key) const;
QString totp() const; QString totp(bool* isValid = nullptr) const;
QString totpSettingsString() const; QString totpSettingsString() const;
QSharedPointer<Totp::Settings> totpSettings() const; QSharedPointer<Totp::Settings> totpSettings() const;
Group* previousParentGroup(); Group* previousParentGroup();
@@ -126,6 +126,7 @@ public:
void removePasskey(); void removePasskey();
bool hasTotp() const; bool hasTotp() const;
bool hasValidTotp() const;
bool isExpired() const; bool isExpired() const;
bool willExpireInDays(int days) const; bool willExpireInDays(int days) const;
void expireNow(); void expireNow();

View File

@@ -210,12 +210,33 @@ QString Totp::writeSettings(const QSharedPointer<Totp::Settings>& settings,
} }
} }
QString Totp::generateTotp(const QSharedPointer<Totp::Settings>& settings, const quint64 time) QString Totp::checkValidSettings(const QSharedPointer<Totp::Settings>& settings)
{ {
Q_ASSERT(!settings.isNull());
if (settings.isNull()) { if (settings.isNull()) {
return QObject::tr("Invalid Settings", "TOTP"); return QObject::tr("Invalid Settings", "TOTP");
} }
QVariant secret = Base32::decode(Base32::sanitizeInput(settings->key.toLatin1()));
if (secret.isNull()) {
return QObject::tr("Invalid Key", "TOTP");
}
if (settings->step == 0) {
return QObject::tr("Invalid Step", "TOTP");
}
if (settings->digits == 0) {
return QObject::tr("Invalid Digits", "TOTP");
}
return {};
}
QString Totp::generateTotp(const QSharedPointer<Totp::Settings>& settings, bool* isValid, const quint64 time)
{
auto error = checkValidSettings(settings);
if (!error.isEmpty()) {
if (isValid) {
*isValid = false;
}
return error;
}
const Encoder& encoder = settings->encoder; const Encoder& encoder = settings->encoder;
uint step = settings->step; uint step = settings->step;
@@ -229,9 +250,6 @@ QString Totp::generateTotp(const QSharedPointer<Totp::Settings>& settings, const
} }
QVariant secret = Base32::decode(Base32::sanitizeInput(settings->key.toLatin1())); QVariant secret = Base32::decode(Base32::sanitizeInput(settings->key.toLatin1()));
if (secret.isNull()) {
return QObject::tr("Invalid Key", "TOTP");
}
QCryptographicHash::Algorithm cryptoHash; QCryptographicHash::Algorithm cryptoHash;
switch (settings->algorithm) { switch (settings->algorithm) {
@@ -274,6 +292,9 @@ QString Totp::generateTotp(const QSharedPointer<Totp::Settings>& settings, const
retval[pos] = encoder.alphabet[int(password % encoder.alphabet.size())]; retval[pos] = encoder.alphabet[int(password % encoder.alphabet.size())];
password /= encoder.alphabet.size(); password /= encoder.alphabet.size();
} }
if (isValid) {
*isValid = true;
}
return retval; return retval;
} }

View File

@@ -91,8 +91,10 @@ namespace Totp
const QString& title = {}, const QString& title = {},
const QString& username = {}, const QString& username = {},
bool forceOtp = false); bool forceOtp = false);
// Returns an empty string if settings are valid, otherwise an error message is supplied
QString generateTotp(const QSharedPointer<Totp::Settings>& settings, const quint64 time = 0ull); QString checkValidSettings(const QSharedPointer<Totp::Settings>& settings);
QString
generateTotp(const QSharedPointer<Totp::Settings>& settings, bool* isValid = nullptr, const quint64 time = 0ull);
bool hasCustomSettings(const QSharedPointer<Totp::Settings>& settings); bool hasCustomSettings(const QSharedPointer<Totp::Settings>& settings);

View File

@@ -125,7 +125,7 @@ namespace FdoSecrets
// add some informative and readonly attributes // add some informative and readonly attributes
attrs[ItemAttributes::UuidKey] = m_backend->uuidToHex(); attrs[ItemAttributes::UuidKey] = m_backend->uuidToHex();
attrs[ItemAttributes::PathKey] = path(); attrs[ItemAttributes::PathKey] = path();
if (m_backend->hasTotp()) { if (m_backend->hasValidTotp()) {
attrs[ItemAttributes::TotpKey] = m_backend->totp(); attrs[ItemAttributes::TotpKey] = m_backend->totp();
} }
return {}; return {};

View File

@@ -1527,7 +1527,7 @@ void DatabaseWidget::entryActivationSignalReceived(Entry* entry, EntryModel::Mod
} }
break; break;
case EntryModel::Totp: case EntryModel::Totp:
if (entry->hasTotp()) { if (entry->hasValidTotp()) {
setClipboardTextAndMinimize(entry->totp()); setClipboardTextAndMinimize(entry->totp());
} else { } else {
setupTotp(); setupTotp();
@@ -2386,7 +2386,7 @@ bool DatabaseWidget::currentEntryHasTotp()
if (!currentEntry) { if (!currentEntry) {
return false; return false;
} }
return currentEntry->hasTotp(); return currentEntry->hasValidTotp();
} }
#ifdef WITH_XC_SSHAGENT #ifdef WITH_XC_SSHAGENT

View File

@@ -70,8 +70,7 @@ EntryPreviewWidget::EntryPreviewWidget(QWidget* parent)
m_ui->entryTotpLabel->installEventFilter(this); m_ui->entryTotpLabel->installEventFilter(this);
connect(m_ui->entryTotpButton, SIGNAL(toggled(bool)), m_ui->entryTotpLabel, SLOT(setVisible(bool))); connect(m_ui->entryTotpButton, SIGNAL(toggled(bool)), m_ui->entryTotp, SLOT(setVisible(bool)));
connect(m_ui->entryTotpButton, SIGNAL(toggled(bool)), m_ui->entryTotpProgress, SLOT(setVisible(bool)));
connect(m_ui->entryCloseButton, SIGNAL(clicked()), SLOT(hide())); connect(m_ui->entryCloseButton, SIGNAL(clicked()), SLOT(hide()));
connect(m_ui->toggleUsernameButton, SIGNAL(clicked(bool)), SLOT(setUsernameVisible(bool))); connect(m_ui->toggleUsernameButton, SIGNAL(clicked(bool)), SLOT(setUsernameVisible(bool)));
connect(m_ui->togglePasswordButton, SIGNAL(clicked(bool)), SLOT(setPasswordVisible(bool))); connect(m_ui->togglePasswordButton, SIGNAL(clicked(bool)), SLOT(setPasswordVisible(bool)));
@@ -260,8 +259,7 @@ void EntryPreviewWidget::updateEntryTotp()
m_ui->entryTotpProgress->setMaximum(m_currentEntry->totpSettings()->step); m_ui->entryTotpProgress->setMaximum(m_currentEntry->totpSettings()->step);
updateTotpLabel(); updateTotpLabel();
} else { } else {
m_ui->entryTotpLabel->hide(); m_ui->entryTotp->hide();
m_ui->entryTotpProgress->hide();
m_ui->entryTotpButton->setChecked(false); m_ui->entryTotpButton->setChecked(false);
m_ui->entryTotpLabel->clear(); m_ui->entryTotpLabel->clear();
m_totpTimer.stop(); m_totpTimer.stop();
@@ -546,16 +544,23 @@ void EntryPreviewWidget::updateGroupSharingTab()
void EntryPreviewWidget::updateTotpLabel() void EntryPreviewWidget::updateTotpLabel()
{ {
if (!m_locked && m_currentEntry && m_currentEntry->hasTotp()) { if (!m_locked && m_currentEntry && m_currentEntry->hasTotp()) {
auto totpCode = m_currentEntry->totp(); bool isValid = false;
auto totpCode = m_currentEntry->totp(&isValid);
if (isValid) {
totpCode.insert(totpCode.size() / 2, " "); totpCode.insert(totpCode.size() / 2, " ");
m_ui->entryTotpLabel->setText(totpCode);
auto step = m_currentEntry->totpSettings()->step; auto step = m_currentEntry->totpSettings()->step;
auto timeleft = step - (Clock::currentSecondsSinceEpoch() % step); auto timeleft = step - (Clock::currentSecondsSinceEpoch() % step);
m_ui->entryTotpProgress->setValue(timeleft); m_ui->entryTotpProgress->setValue(timeleft);
m_ui->entryTotpProgress->update(); m_ui->entryTotpProgress->update();
} else { } else {
m_ui->entryTotpLabel->clear(); m_totpTimer.stop();
}
m_ui->entryTotpProgress->setVisible(isValid);
m_ui->entryTotpLabel->setText(totpCode);
} else {
m_ui->entryTotp->setVisible(false);
m_totpTimer.stop(); m_totpTimer.stop();
} }
} }

View File

@@ -110,16 +110,34 @@
</layout> </layout>
</item> </item>
<item> <item>
<layout class="QVBoxLayout" name="verticalLayout"> <widget class="QWidget" name="entryTotp" native="true">
<property name="minimumSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<property name="spacing"> <property name="spacing">
<number>0</number> <number>0</number>
</property> </property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item> <item>
<widget class="QLabel" name="entryTotpLabel"> <widget class="QLabel" name="entryTotpLabel">
<property name="font"> <property name="font">
<font> <font>
<pointsize>10</pointsize> <pointsize>10</pointsize>
<weight>75</weight>
<bold>true</bold> <bold>true</bold>
</font> </font>
</property> </property>
@@ -130,7 +148,7 @@
<string notr="true">1234567</string> <string notr="true">1234567</string>
</property> </property>
<property name="textInteractionFlags"> <property name="textInteractionFlags">
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set> <set>Qt::TextInteractionFlag::LinksAccessibleByMouse|Qt::TextInteractionFlag::TextSelectableByKeyboard|Qt::TextInteractionFlag::TextSelectableByMouse</set>
</property> </property>
</widget> </widget>
</item> </item>
@@ -151,6 +169,7 @@
</widget> </widget>
</item> </item>
</layout> </layout>
</widget>
</item> </item>
<item> <item>
<widget class="QToolButton" name="entryTotpButton"> <widget class="QToolButton" name="entryTotpButton">

View File

@@ -39,6 +39,7 @@ TotpDialog::TotpDialog(QWidget* parent, Entry* entry)
m_step = m_entry->totpSettings()->step; m_step = m_entry->totpSettings()->step;
resetCounter(); resetCounter();
updateProgressBar(); updateProgressBar();
updateSeconds();
connect(&m_totpUpdateTimer, SIGNAL(timeout()), this, SLOT(updateProgressBar())); connect(&m_totpUpdateTimer, SIGNAL(timeout()), this, SLOT(updateProgressBar()));
connect(&m_totpUpdateTimer, SIGNAL(timeout()), this, SLOT(updateSeconds())); connect(&m_totpUpdateTimer, SIGNAL(timeout()), this, SLOT(updateSeconds()));
@@ -88,10 +89,15 @@ void TotpDialog::updateSeconds()
void TotpDialog::updateTotp() void TotpDialog::updateTotp()
{ {
QString totpCode = m_entry->totp(); bool isValid = false;
QString firstHalf = totpCode.left(totpCode.size() / 2); QString totpCode = m_entry->totp(&isValid);
QString secondHalf = totpCode.mid(totpCode.size() / 2); if (isValid) {
m_ui->totpLabel->setText(firstHalf + " " + secondHalf); totpCode.insert(totpCode.size() / 2, " ");
}
m_ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(isValid);
m_ui->progressBar->setVisible(isValid);
m_ui->timerLabel->setVisible(isValid);
m_ui->totpLabel->setText(totpCode);
} }
void TotpDialog::resetCounter() void TotpDialog::resetCounter()

View File

@@ -127,5 +127,8 @@ void TotpSetupDialog::init()
m_ui->algorithmComboBox->setCurrentIndex(index); m_ui->algorithmComboBox->setCurrentIndex(index);
} }
} }
auto error = Totp::checkValidSettings(settings);
m_ui->invalidKeyLabel->setVisible(!error.isEmpty());
} }
} }

View File

@@ -14,6 +14,22 @@
<string>Setup TOTP</string> <string>Setup TOTP</string>
</property> </property>
<layout class="QVBoxLayout" name="verticalLayout"> <layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="invalidKeyLabel">
<property name="font">
<font>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>Error: secret key is invalid</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item> <item>
<layout class="QHBoxLayout" name="horizontalLayout_2"> <layout class="QHBoxLayout" name="horizontalLayout_2">
<property name="leftMargin"> <property name="leftMargin">
@@ -210,6 +226,7 @@
<zorder>customSettingsGroup</zorder> <zorder>customSettingsGroup</zorder>
<zorder>buttonBox</zorder> <zorder>buttonBox</zorder>
<zorder>groupBox</zorder> <zorder>groupBox</zorder>
<zorder>invalidKeyLabel</zorder>
</widget> </widget>
<tabstops> <tabstops>
<tabstop>seedEdit</tabstop> <tabstop>seedEdit</tabstop>

View File

@@ -297,7 +297,7 @@ QVariant EntryModel::data(const QModelIndex& index, int role) const
break; break;
case Totp: case Totp:
if (entry->hasTotp()) { if (entry->hasTotp()) {
return icons()->icon("totp"); return entry->hasValidTotp() ? icons()->icon("totp") : icons()->icon("totp-invalid");
} }
break; break;
case PasswordStrength: case PasswordStrength:

View File

@@ -115,18 +115,18 @@ void TestTotp::testTotpCode()
// Test 6 digit TOTP (default) // Test 6 digit TOTP (default)
quint64 time = 1234567890; quint64 time = 1234567890;
QCOMPARE(Totp::generateTotp(settings, time), QString("005924")); QCOMPARE(Totp::generateTotp(settings, nullptr, time), QString("005924"));
time = 1111111109; time = 1111111109;
QCOMPARE(Totp::generateTotp(settings, time), QString("081804")); QCOMPARE(Totp::generateTotp(settings, nullptr, time), QString("081804"));
// Test 8 digit TOTP (custom) // Test 8 digit TOTP (custom)
settings->digits = 8; settings->digits = 8;
time = 1111111111; time = 1111111111;
QCOMPARE(Totp::generateTotp(settings, time), QString("14050471")); QCOMPARE(Totp::generateTotp(settings, nullptr, time), QString("14050471"));
time = 2000000000; time = 2000000000;
QCOMPARE(Totp::generateTotp(settings, time), QString("69279037")); QCOMPARE(Totp::generateTotp(settings, nullptr, time), QString("69279037"));
} }
void TestTotp::testSteamTotp() void TestTotp::testSteamTotp()
@@ -155,9 +155,9 @@ void TestTotp::testSteamTotp()
// Steam mobile app with a throw-away steam account. The above secret was extracted // Steam mobile app with a throw-away steam account. The above secret was extracted
// from the Steam app's data for use in testing here. // from the Steam app's data for use in testing here.
quint64 time = 1511200518; quint64 time = 1511200518;
QCOMPARE(Totp::generateTotp(settings, time), QString("FR8RV")); QCOMPARE(Totp::generateTotp(settings, nullptr, time), QString("FR8RV"));
time = 1511200714; time = 1511200714;
QCOMPARE(Totp::generateTotp(settings, time), QString("9P3VP")); QCOMPARE(Totp::generateTotp(settings, nullptr, time), QString("9P3VP"));
} }
void TestTotp::testEntryHistory() void TestTotp::testEntryHistory()