diff --git a/src/core/Entry.cpp b/src/core/Entry.cpp index 91d757a91..eab79d6aa 100644 --- a/src/core/Entry.cpp +++ b/src/core/Entry.cpp @@ -25,6 +25,8 @@ #include "core/Metadata.h" #include "totp/totp.h" +#include + const int Entry::DefaultIconNumber = 0; const int Entry::ResolveMaximumDepth = 10; @@ -332,12 +334,24 @@ void Entry::setTotp(const QString& seed, quint8& step, quint8& digits) if (digits == 0) { digits = QTotp::defaultDigits; } + QString data; + + const QTotp::Encoder & enc = QTotp::encoders.value(digits, QTotp::defaultEncoder); if (m_attributes->hasKey("otp")) { - m_attributes->set("otp", QString("key=%1&step=%2&size=%3").arg(seed).arg(step).arg(digits), true); + data = QString("key=%1&step=%2&size=%3").arg(seed).arg(step).arg(enc.digits == 0 ? digits : enc.digits); + if (!enc.name.isEmpty()) { + data.append("&enocder=").append(enc.name); + } + m_attributes->set("otp", data, true); } else { m_attributes->set("TOTP Seed", seed, true); - m_attributes->set("TOTP Settings", QString("%1;%2").arg(step).arg(digits)); + if (!enc.shortName.isEmpty()) { + data = QString("%1;%2").arg(step).arg(enc.shortName); + } else { + data = QString("%1;%2").arg(step).arg(digits); + } + m_attributes->set("TOTP Settings", data); } } @@ -355,11 +369,16 @@ QString Entry::totpSeed() const m_data.totpStep = QTotp::defaultStep; if (m_attributes->hasKey("TOTP Settings")) { - QRegExp rx("(\\d+);(\\d)", Qt::CaseInsensitive, QRegExp::RegExp); - int pos = rx.indexIn(m_attributes->value("TOTP Settings")); - if (pos > -1) { - m_data.totpStep = rx.cap(1).toUInt(); - m_data.totpDigits = rx.cap(2).toUInt(); + // this regex must be kept in sync with the set of allowed short names QTotp::shortNameToEncoder + QRegularExpression rx(QString("(\\d+);((?:\\d+)|S)")); + QRegularExpressionMatch m = rx.match(m_attributes->value("TOTP Settings")); + if (m.hasMatch()) { + m_data.totpStep = m.captured(1).toUInt(); + if (QTotp::shortNameToEncoder.contains(m.captured(2))) { + m_data.totpDigits = QTotp::shortNameToEncoder[m.captured(2)]; + } else { + m_data.totpDigits = m.captured(2).toUInt(); + } } } diff --git a/src/gui/DatabaseWidget.cpp b/src/gui/DatabaseWidget.cpp index e5110e30a..a0671af98 100644 --- a/src/gui/DatabaseWidget.cpp +++ b/src/gui/DatabaseWidget.cpp @@ -417,6 +417,8 @@ void DatabaseWidget::setupTotp() setupTotpDialog->setSeed(currentEntry->totpSeed()); setupTotpDialog->setStep(currentEntry->totpStep()); setupTotpDialog->setDigits(currentEntry->totpDigits()); + // now that all settings are set, decide whether it's default, steam or custom + setupTotpDialog->setSettings(currentEntry->totpDigits()); } setupTotpDialog->open(); diff --git a/src/gui/DetailsWidget.cpp b/src/gui/DetailsWidget.cpp index c8dc058d9..23c3485ad 100644 --- a/src/gui/DetailsWidget.cpp +++ b/src/gui/DetailsWidget.cpp @@ -270,7 +270,7 @@ void DetailsWidget::updateTotp() if (!m_locked) { QString totpCode = m_currentEntry->totp(); QString firstHalf = totpCode.left(totpCode.size() / 2); - QString secondHalf = totpCode.right(totpCode.size() / 2); + QString secondHalf = totpCode.mid(totpCode.size() / 2); m_ui->totpLabel->setText(firstHalf + " " + secondHalf); } else if (nullptr != m_timer) { m_timer->stop(); diff --git a/src/gui/SetupTotpDialog.cpp b/src/gui/SetupTotpDialog.cpp index 5521773bd..2e6c91f00 100644 --- a/src/gui/SetupTotpDialog.cpp +++ b/src/gui/SetupTotpDialog.cpp @@ -35,7 +35,9 @@ SetupTotpDialog::SetupTotpDialog(DatabaseWidget* parent, Entry* entry) connect(m_ui->buttonBox, SIGNAL(rejected()), SLOT(close())); connect(m_ui->buttonBox, SIGNAL(accepted()), SLOT(setupTotp())); - connect(m_ui->customSettingsCheckBox, SIGNAL(toggled(bool)), SLOT(toggleCustom(bool))); + connect(m_ui->radioDefault, SIGNAL(toggled(bool)), SLOT(toggleDefault(bool))); + connect(m_ui->radioSteam, SIGNAL(toggled(bool)), SLOT(toggleSteam(bool))); + connect(m_ui->radioCustom, SIGNAL(toggled(bool)), SLOT(toggleCustom(bool))); } @@ -43,7 +45,9 @@ void SetupTotpDialog::setupTotp() { quint8 digits; - if (m_ui->radio8Digits->isChecked()) { + if (m_ui->radioSteam->isChecked()) { + digits = QTotp::ENCODER_STEAM; + } else if (m_ui->radio8Digits->isChecked()) { digits = 8; } else { digits = 6; @@ -56,6 +60,22 @@ void SetupTotpDialog::setupTotp() close(); } +void SetupTotpDialog::toggleDefault(bool status) +{ + if (status) { + setStep(QTotp::defaultStep); + setDigits(QTotp::defaultDigits); + } +} + +void SetupTotpDialog::toggleSteam(bool status) +{ + if (status) { + setStep(QTotp::defaultStep); + setDigits(QTotp::ENCODER_STEAM); + } +} + void SetupTotpDialog::toggleCustom(bool status) { m_ui->digitsLabel->setEnabled(status); @@ -72,13 +92,25 @@ void SetupTotpDialog::setSeed(QString value) m_ui->seedEdit->setText(value); } +void SetupTotpDialog::setSettings(quint8 digits) { + quint8 step = m_ui->stepSpinBox->value(); + + bool isDefault = ((step == QTotp::defaultStep) && + (digits == QTotp::defaultDigits)); + bool isSteam = (digits == QTotp::ENCODER_STEAM); + + if (isSteam) { + m_ui->radioSteam->setChecked(true); + } else if (isDefault) { + m_ui->radioDefault->setChecked(true); + } else { + m_ui->radioCustom->setChecked(true); + } +} + void SetupTotpDialog::setStep(quint8 step) { m_ui->stepSpinBox->setValue(step); - - if (step != QTotp::defaultStep) { - m_ui->customSettingsCheckBox->setChecked(true); - } } void SetupTotpDialog::setDigits(quint8 digits) @@ -90,13 +122,8 @@ void SetupTotpDialog::setDigits(quint8 digits) m_ui->radio6Digits->setChecked(true); m_ui->radio8Digits->setChecked(false); } - - if (digits != QTotp::defaultDigits) { - m_ui->customSettingsCheckBox->setChecked(true); - } } - SetupTotpDialog::~SetupTotpDialog() { } diff --git a/src/gui/SetupTotpDialog.h b/src/gui/SetupTotpDialog.h index 243a05f9f..9e90e9686 100644 --- a/src/gui/SetupTotpDialog.h +++ b/src/gui/SetupTotpDialog.h @@ -39,8 +39,11 @@ public: void setSeed(QString value); void setStep(quint8 step); void setDigits(quint8 digits); + void setSettings(quint8 digits); private Q_SLOTS: + void toggleDefault(bool status); + void toggleSteam(bool status); void toggleCustom(bool status); void setupTotp(); diff --git a/src/gui/SetupTotpDialog.ui b/src/gui/SetupTotpDialog.ui index a6d806287..c3a83e21d 100644 --- a/src/gui/SetupTotpDialog.ui +++ b/src/gui/SetupTotpDialog.ui @@ -7,7 +7,7 @@ 0 0 282 - 257 + 364 @@ -29,11 +29,38 @@ - - - Use custom settings - - + + + + + Default RFC 6238 token settings + + + settingsButtonGroup + + + + + + + Steam token settings + + + settingsButtonGroup + + + + + + + Use custom settings + + + settingsButtonGroup + + + + @@ -134,4 +161,7 @@ + + + diff --git a/src/gui/TotpDialog.cpp b/src/gui/TotpDialog.cpp index 17cc1120f..474acf773 100644 --- a/src/gui/TotpDialog.cpp +++ b/src/gui/TotpDialog.cpp @@ -87,8 +87,8 @@ void TotpDialog::updateSeconds() void TotpDialog::updateTotp() { QString totpCode = m_entry->totp(); - QString firstHalf = totpCode.left(totpCode.size()/2); - QString secondHalf = totpCode.right(totpCode.size()/2); + QString firstHalf = totpCode.left(totpCode.size() / 2); + QString secondHalf = totpCode.mid(totpCode.size() / 2); m_ui->totpLabel->setText(firstHalf + " " + secondHalf); } diff --git a/src/totp/totp.cpp b/src/totp/totp.cpp index 7a584def2..f17def8a2 100644 --- a/src/totp/totp.cpp +++ b/src/totp/totp.cpp @@ -31,6 +31,39 @@ const quint8 QTotp::defaultStep = 30; const quint8 QTotp::defaultDigits = 6; +/** + * Custom encoder types. Each should be unique and >= 128 and < 255 + * Values have no meaning outside of keepassxc + */ +/** + * Encoder for Steam Guard TOTP + */ +const quint8 QTotp::ENCODER_STEAM = 254; + +const QTotp::Encoder QTotp::defaultEncoder = { "", "", "0123456789", 0, 0, false }; +const QMap QTotp::encoders{ + { QTotp::ENCODER_STEAM, { "steam", "S", "23456789BCDFGHJKMNPQRTVWXY", 5, 30, true } }, +}; + +/** + * These map the second field of the "TOTP Settings" field to our internal encoder number + * that overloads the digits field. Make sure that the key matches the shortName value + * in the corresponding Encoder + * NOTE: when updating this map, a corresponding edit to the settings regex must be made + * in Entry::totpSeed() + */ +const QMap QTotp::shortNameToEncoder{ + { "S", QTotp::ENCODER_STEAM }, +}; +/** + * These map the "encoder=" URL parameter of the "otp" field to our internal encoder number + * that overloads the digits field. Make sure that the key matches the name value + * in the corresponding Encoder + */ +const QMap QTotp::nameToEncoder{ + { "steam", QTotp::ENCODER_STEAM }, +}; + QTotp::QTotp() { } @@ -57,7 +90,10 @@ QString QTotp::parseOtpString(QString key, quint8& digits, quint8& step) if (q_step > 0 && q_step <= 60) { step = q_step; } - + QString encName = query.queryItemValue("encoder"); + if (!encName.isEmpty() && nameToEncoder.contains(encName)) { + digits = nameToEncoder[encName]; + } } else { // Compatibility with "KeeOtp" plugin string format QRegExp rx("key=(.+)", Qt::CaseInsensitive, QRegExp::RegExp); @@ -119,10 +155,24 @@ QString QTotp::generateTotp(const QByteArray key, | (hmac[offset + 3] & 0xff); // clang-format on - quint32 digitsPower = pow(10, numDigits); + const Encoder& encoder = encoders.value(numDigits, defaultEncoder); + // if encoder.digits is 0, we need to use the passed-in number of digits (default encoder) + quint8 digits = encoder.digits == 0 ? numDigits : encoder.digits; + int direction = -1; + int startpos = digits - 1; + if (encoder.reverse) { + direction = 1; + startpos = 0; + } + quint32 digitsPower = pow(encoder.alphabet.size(), digits); quint64 password = binary % digitsPower; - return QString("%1").arg(password, numDigits, 10, QChar('0')); + QString retval(int(digits), encoder.alphabet[0]); + for (quint8 pos = startpos; password > 0; pos += direction) { + retval[pos] = encoder.alphabet[int(password % encoder.alphabet.size())]; + password /= encoder.alphabet.size(); + } + return retval; } // See: https://github.com/google/google-authenticator/wiki/Key-Uri-Format @@ -131,8 +181,8 @@ QUrl QTotp::generateOtpString(const QString& secret, const QString& issuer, const QString& username, const QString& algorithm, - const quint8& digits, - const quint8& step) + quint8 digits, + quint8 step) { QUrl keyUri; keyUri.setScheme("otpauth"); diff --git a/src/totp/totp.h b/src/totp/totp.h index d5d8aa679..d67298712 100644 --- a/src/totp/totp.h +++ b/src/totp/totp.h @@ -20,6 +20,8 @@ #define QTOTP_H #include +#include +#include class QUrl; @@ -34,10 +36,25 @@ public: const QString& issuer, const QString& username, const QString& algorithm, - const quint8& digits, - const quint8& step); + quint8 digits, + quint8 step); static const quint8 defaultStep; static const quint8 defaultDigits; + struct Encoder + { + QString name; + QString shortName; + QString alphabet; + quint8 digits; + quint8 step; + bool reverse; + }; + static const Encoder defaultEncoder; + // custom encoder values that overload the digits field + static const quint8 ENCODER_STEAM; + static const QMap encoders; + static const QMap shortNameToEncoder; + static const QMap nameToEncoder; }; #endif // QTOTP_H diff --git a/tests/TestTotp.cpp b/tests/TestTotp.cpp index 48ff88144..288c047a0 100644 --- a/tests/TestTotp.cpp +++ b/tests/TestTotp.cpp @@ -83,3 +83,89 @@ void TestTotp::testTotpCode() output = QTotp::generateTotp(seed, time, 8, 30); QCOMPARE(output, QString("69279037")); } + +void TestTotp::testEncoderData() +{ + for (quint8 key: QTotp::encoders.keys()) { + const QTotp::Encoder& enc = QTotp::encoders.value(key); + QVERIFY2(enc.digits != 0, + qPrintable(QString("Custom encoders cannot have zero-value for digits field: %1(%2)") + .arg(enc.name) + .arg(key))); + QVERIFY2(!enc.name.isEmpty(), + qPrintable(QString("Custom encoders must have a name: %1(%2)") + .arg(enc.name) + .arg(key))); + QVERIFY2(!enc.shortName.isEmpty(), + qPrintable(QString("Custom encoders must have a shortName: %1(%2)") + .arg(enc.name) + .arg(key))); + QVERIFY2(QTotp::shortNameToEncoder.contains(enc.shortName), + qPrintable(QString("No shortNameToEncoder entry found for custom encoder: %1(%2) %3") + .arg(enc.name) + .arg(key) + .arg(enc.shortName))); + QVERIFY2(QTotp::shortNameToEncoder[enc.shortName] == key, + qPrintable(QString("shortNameToEncoder doesn't reference this custome encoder: %1(%2) %3") + .arg(enc.name) + .arg(key) + .arg(enc.shortName))); + QVERIFY2(QTotp::nameToEncoder.contains(enc.name), + qPrintable(QString("No nameToEncoder entry found for custom encoder: %1(%2) %3") + .arg(enc.name) + .arg(key) + .arg(enc.shortName))); + QVERIFY2(QTotp::nameToEncoder[enc.name] == key, + qPrintable(QString("nameToEncoder doesn't reference this custome encoder: %1(%2) %3") + .arg(enc.name) + .arg(key) + .arg(enc.shortName))); + } + + for (const QString & key: QTotp::nameToEncoder.keys()) { + quint8 value = QTotp::nameToEncoder.value(key); + QVERIFY2(QTotp::encoders.contains(value), + qPrintable(QString("No custom encoder found for encoder named %1(%2)") + .arg(value) + .arg(key))); + QVERIFY2(QTotp::encoders[value].name == key, + qPrintable(QString("nameToEncoder doesn't reference the right custom encoder: %1(%2)") + .arg(value) + .arg(key))); + } + + for (const QString & key: QTotp::shortNameToEncoder.keys()) { + quint8 value = QTotp::shortNameToEncoder.value(key); + QVERIFY2(QTotp::encoders.contains(value), + qPrintable(QString("No custom encoder found for short-name encoder %1(%2)") + .arg(value) + .arg(key))); + QVERIFY2(QTotp::encoders[value].shortName == key, + qPrintable(QString("shortNameToEncoder doesn't reference the right custom encoder: %1(%2)") + .arg(value) + .arg(key))); + } +} + +void TestTotp::testSteamTotp() +{ + quint8 digits = 0; + quint8 step = 0; + QString secret = "otpauth://totp/" + "test:test@example.com?secret=63BEDWCQZKTQWPESARIERL5DTTQFCJTK&issuer=Valve&algorithm=" + "SHA1&digits=5&period=30&encoder=steam"; + QCOMPARE(QTotp::parseOtpString(secret, digits, step), QString("63BEDWCQZKTQWPESARIERL5DTTQFCJTK")); + QCOMPARE(digits, quint8(QTotp::ENCODER_STEAM)); + QCOMPARE(step, quint8(30)); + + + QByteArray seed = QString("63BEDWCQZKTQWPESARIERL5DTTQFCJTK").toLatin1(); + + // These time/value pairs were created by running the Steam Guard function of the + // 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. + quint64 time = 1511200518; + QCOMPARE(QTotp::generateTotp(seed, time, QTotp::ENCODER_STEAM, 30), QString("FR8RV")); + time = 1511200714; + QCOMPARE(QTotp::generateTotp(seed, time, QTotp::ENCODER_STEAM, 30), QString("9P3VP")); +} diff --git a/tests/TestTotp.h b/tests/TestTotp.h index 785a9f522..3bf2de93f 100644 --- a/tests/TestTotp.h +++ b/tests/TestTotp.h @@ -31,6 +31,8 @@ private slots: void initTestCase(); void testParseSecret(); void testTotpCode(); + void testEncoderData(); + void testSteamTotp(); }; #endif // KEEPASSX_TESTTOTP_H