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:
Joel Smith 2017-11-20 11:01:22 -07:00
parent 1cb91fabb6
commit 8ca52ba8f9
11 changed files with 270 additions and 34 deletions

View File

@ -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();
}
}
}

View File

@ -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();

View File

@ -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();

View File

@ -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()
{
}

View File

@ -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();

View File

@ -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>

View File

@ -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);
}

View File

@ -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");

View File

@ -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

View File

@ -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"));
}

View File

@ -31,6 +31,8 @@ private slots:
void initTestCase();
void testParseSecret();
void testTotpCode();
void testEncoderData();
void testSteamTotp();
};
#endif // KEEPASSX_TESTTOTP_H