mirror of
https://github.com/keepassxreboot/keepassxc.git
synced 2024-10-01 01:26:01 -04:00
Add initial Steam TOTP support
* Add the concept of custom TOTP encoders, each with potential for custom code alphabet, length, step interval and code direction (i.e. reversed) * Select custom encoder via overload of the digits field of a loaded entry * Allow selection of custom encoders via the "TOTP Settings" field's size, as currently done by KeeTrayTOTP for Steam. Use "S" for the short name of the Steam custom encoder * Allow selection of custom encoders via the "otp" field by appending a "&encoder=<name>" field to the URL query. For example, "&encoder=steam" * Update TOTP set-up dialog to permit selection between (default, steam, custom) settings.
This commit is contained in:
parent
1cb91fabb6
commit
8ca52ba8f9
@ -25,6 +25,8 @@
|
||||
#include "core/Metadata.h"
|
||||
#include "totp/totp.h"
|
||||
|
||||
#include <QRegularExpression>
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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();
|
||||
|
@ -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()
|
||||
{
|
||||
}
|
||||
|
@ -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();
|
||||
|
||||
|
@ -7,7 +7,7 @@
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>282</width>
|
||||
<height>257</height>
|
||||
<height>364</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
@ -29,11 +29,38 @@
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="customSettingsCheckBox">
|
||||
<property name="text">
|
||||
<string>Use custom settings</string>
|
||||
</property>
|
||||
</widget>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<widget class="QRadioButton" name="radioDefault">
|
||||
<property name="text">
|
||||
<string>Default RFC 6238 token settings</string>
|
||||
</property>
|
||||
<attribute name="buttonGroup">
|
||||
<string notr="true">settingsButtonGroup</string>
|
||||
</attribute>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QRadioButton" name="radioSteam">
|
||||
<property name="text">
|
||||
<string>Steam token settings</string>
|
||||
</property>
|
||||
<attribute name="buttonGroup">
|
||||
<string notr="true">settingsButtonGroup</string>
|
||||
</attribute>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QRadioButton" name="radioCustom">
|
||||
<property name="text">
|
||||
<string>Use custom settings</string>
|
||||
</property>
|
||||
<attribute name="buttonGroup">
|
||||
<string notr="true">settingsButtonGroup</string>
|
||||
</attribute>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label_4">
|
||||
@ -134,4 +161,7 @@
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
<buttongroups>
|
||||
<buttongroup name="settingsButtonGroup"/>
|
||||
</buttongroups>
|
||||
</ui>
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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<quint8, QTotp::Encoder> 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<QString, quint8> 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<QString, quint8> 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");
|
||||
|
@ -20,6 +20,8 @@
|
||||
#define QTOTP_H
|
||||
|
||||
#include <QtCore/qglobal.h>
|
||||
#include <QString>
|
||||
#include <QMap>
|
||||
|
||||
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<quint8, Encoder> encoders;
|
||||
static const QMap<QString, quint8> shortNameToEncoder;
|
||||
static const QMap<QString, quint8> nameToEncoder;
|
||||
};
|
||||
|
||||
#endif // QTOTP_H
|
||||
|
@ -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"));
|
||||
}
|
||||
|
@ -31,6 +31,8 @@ private slots:
|
||||
void initTestCase();
|
||||
void testParseSecret();
|
||||
void testTotpCode();
|
||||
void testEncoderData();
|
||||
void testSteamTotp();
|
||||
};
|
||||
|
||||
#endif // KEEPASSX_TESTTOTP_H
|
||||
|
Loading…
Reference in New Issue
Block a user