feat: add basic support for otpauth:// URI parameter model

Add support converting an otpauth:// URI into a model object.
Validation is quite lax and focused on what Keysmith can recover from within the scope of UI/UX for adding accounts via QR codes.

See-Also: https://github.com/google/google-authenticator/wiki/Key-Uri-Format
Issues: #14
master
Johan Ouwerkerk 2020-08-18 21:43:55 +02:00
parent db51ce9e3f
commit 420198c49a
5 changed files with 329 additions and 2 deletions

View File

@ -5,4 +5,9 @@
set(Test_DEP_LIBS Qt5::Core Qt5::Test model_lib)
ecm_add_test(milliseconds-left-for-token.cpp LINK_LIBRARIES ${Test_DEP_LIBS} TEST_NAME milliseconds-left-for-token NAME_PREFIX model-)
set(model_test_SRCS
milliseconds-left-for-token.cpp
qr-input.cpp
)
ecm_add_tests(${model_test_SRCS} LINK_LIBRARIES ${Test_DEP_LIBS} NAME_PREFIX model-)

View File

@ -0,0 +1,108 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
* SPDX-FileCopyrightText: 2020 Johan Ouwerkerk <jm.ouwerkerk@gmail.com>
*/
#include "model/qr.h"
#include "model/input.h"
#include <QTest>
class QrInputTest: public QObject // clazy:exclude=ctor-missing-parent-argument
{
Q_OBJECT
private Q_SLOTS:
void testValidUri(void);
void testValidUri_data(void);
void testInvalidUri(void);
void testInvalidUri_data(void);
};
static void define_valid_test_case(const char *testCase, const QString &uri, const QString &name,
const QString &issuer, const QString &secret, const QString &counter,
uint tokenLength, uint timeStep, model::AccountInput::TokenType type,
model::AccountInput::TOTPAlgorithm algorithm)
{
QTest::newRow(testCase) << uri << name << issuer << secret << counter << tokenLength << timeStep << type
<< algorithm;
}
static void define_invalid_test_case(const char *testCase, const QString &uri)
{
QTest::newRow(testCase) << uri;
}
void QrInputTest::testValidUri(void)
{
QFETCH(QString, uri);
model::AccountInput target;
auto result = model::QrParameters::parse(uri);
QVERIFY2(result, "should be able to parse valid URIs");
result->populate(&target);
QTEST(target.name(), "name");
QTEST(target.issuer(), "issuer");
QTEST(target.secret(), "secret");
QTEST(target.counter(), "counter");
QTEST(target.tokenLength(), "tokenLength");
QTEST(target.timeStep(), "timeStep");
QTEST(target.type(), "type");
QTEST(target.algorithm(), "algorithm");
}
void QrInputTest::testValidUri_data(void)
{
QTest::addColumn<QString>("uri");
QTest::addColumn<QString>("name");
QTest::addColumn<QString>("issuer");
QTest::addColumn<QString>("secret");
QTest::addColumn<QString>("counter");
QTest::addColumn<uint>("tokenLength");
QTest::addColumn<uint>("timeStep");
QTest::addColumn<model::AccountInput::TokenType>("type");
QTest::addColumn<model::AccountInput::TOTPAlgorithm>("algorithm");
define_valid_test_case("hotp (all fields set)",
QStringLiteral("otpauth://hotp/issuer:valid?secret=VALUE&digits=8&period=60&issuer=issuer&counter=42&algorithm=sha512"),
QStringLiteral("valid"), QStringLiteral("issuer"), QStringLiteral("VALUE==="), QStringLiteral("42"), 8U, 60U,
model::AccountInput::TokenType::Hotp, model::AccountInput::TOTPAlgorithm::Sha512
);
}
void QrInputTest::testInvalidUri(void)
{
QFETCH(QString, uri);
QVERIFY2(!model::QrParameters::parse(uri), "should reject invalid URIs");
}
void QrInputTest::testInvalidUri_data(void)
{
QTest::addColumn<QString>("uri");
define_invalid_test_case("token length not a number", QStringLiteral("otpauth://totp/invalid?secret=VALUE&digits=nan"));
define_invalid_test_case("token length too small", QStringLiteral("otpauth://totp/invalid?secret=VALUE&digits=2"));
define_invalid_test_case("token length too large", QStringLiteral("otpauth://totp/invalid?secret=VALUE&digits=22"));
define_invalid_test_case("time step not a number", QStringLiteral("otpauth://totp/invalid?secret=VALUE&period=nan"));
define_invalid_test_case("time step too small", QStringLiteral("otpauth://totp/invalid?secret=VALUE&period=0"));
define_invalid_test_case("time step too large", QStringLiteral("otpauth://totp/invalid?secret=VALUE&period=999999999999"));
define_invalid_test_case("invalid base32 secret", QStringLiteral("otpauth://totp/invalid?secret=19"));
define_invalid_test_case("empty secret", QStringLiteral("otpauth://totp/invalid?secret="));
define_invalid_test_case("counter not a number", QStringLiteral("otpauth://hotp/invalid?secret=VALUE&counter=nan"));
define_invalid_test_case("invalid algorithm ", QStringLiteral("otpauth://totp/invalid?secret=VALUE&algorithm=foo"));
define_invalid_test_case("name contains non-printable characters", QStringLiteral("otpauth://totp/inv%00alid?secret=VALUE"));
define_invalid_test_case("issuer contains non-printable characters", QStringLiteral("otpauth://totp/iss%00:invalid?secret=VALUE"));
define_invalid_test_case("issuer parameter contains non-printable characters", QStringLiteral("otpauth://totp/invalid?secret=VALUE&issuer=iss%03"));
}
QTEST_APPLESS_MAIN(QrInputTest)
#include "qr-input.moc"

View File

@ -7,7 +7,8 @@ set(model_SRCS
accounts.cpp
password.cpp
input.cpp
qr.cpp
)
add_library(model_lib STATIC ${model_SRCS})
target_link_libraries(model_lib Qt5::Core Qt5::Qml Qt5::Gui account_lib validator_lib)
target_link_libraries(model_lib Qt5::Core Qt5::Qml Qt5::Gui account_lib validator_lib uri_lib)

171
src/model/qr.cpp Normal file
View File

@ -0,0 +1,171 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
* SPDX-FileCopyrightText: 2020 Johan Ouwerkerk <jm.ouwerkerk@gmail.com>
*/
#include "qr.h"
#include "../base32/base32.h"
#include <limits>
static std::optional<quint64> parseUnsigned(const QString &value, const std::function<bool(qulonglong)> &check)
{
bool ok = false;
quint64 v = value.toULongLong(&ok, 10);
return ok && check(v) ? std::optional<quint64>(v) : std::nullopt;
}
static std::optional<uint> parseUnsigned(const QString &value, uint valueIfEmpty, const std::function<bool(quint64)> &check)
{
if (value.isEmpty()) {
return std::optional<uint>(valueIfEmpty);
}
const auto r = parseUnsigned(value, check);
return r ? std::optional<uint>((uint) *r) : std::nullopt;
}
static std::function<bool(quint64)> positiveUpTo(quint64 max)
{
return std::function<bool(quint64)>([max](quint64 v) -> bool
{
return v >= 1ULL && v <= max;
});
}
static std::optional<QString> checkNonEmptyString(const QString &value, const std::function<bool(QString&)> &check)
{
QString v(value);
return v.isEmpty() || !check(v) ? std::nullopt : std::optional<QString>(v);
}
static std::function<bool(QString&)> usableName(const QChar reserved = QLatin1Char('\0'))
{
return std::function<bool(QString &)>([reserved](QString &v) -> bool
{
for (const auto c : v) {
if (!c.isPrint() || c == reserved) {
return false;
}
}
return true;
});
}
namespace model
{
static std::optional<AccountInput::TokenType> convertType(uri::QrParts::Type type)
{
switch (type) {
case uri::QrParts::Type::Hotp:
return std::optional<AccountInput::TokenType>(AccountInput::TokenType::Hotp);
case uri::QrParts::Type::Totp:
return std::optional<AccountInput::TokenType>(AccountInput::TokenType::Totp);
default:
Q_ASSERT_X(false, Q_FUNC_INFO, "Unknown/unsupported otpauth token type?");
return std::nullopt;
}
}
static std::optional<AccountInput::TOTPAlgorithm> convertAlgorithm(const QString &algorithm)
{
static const QString sha1 = QStringLiteral("sha1");
static const QString sha256 = QStringLiteral("sha256");
static const QString sha512 = QStringLiteral("sha512");
if (algorithm.isEmpty()) {
return std::optional<AccountInput::TOTPAlgorithm>(AccountInput::TOTPAlgorithm::Sha1);
}
const auto lower = algorithm.toLower();
if (lower == sha1) {
return std::optional<AccountInput::TOTPAlgorithm>(AccountInput::TOTPAlgorithm::Sha1);
}
if (lower == sha256) {
return std::optional<AccountInput::TOTPAlgorithm>(AccountInput::TOTPAlgorithm::Sha256);
}
if (lower == sha512) {
return std::optional<AccountInput::TOTPAlgorithm>(AccountInput::TOTPAlgorithm::Sha512);
}
return std::nullopt;
}
QrParameters::QrParameters(AccountInput::TokenType type, const QString &name, const QString &issuer,
const QString &secret, uint tokenLength, quint64 counter, uint timeStep,
AccountInput::TOTPAlgorithm algorithm) :
m_type(type), m_name(name), m_issuer(issuer), m_secret(secret), m_tokenLength(tokenLength),
m_counter(counter), m_timeStep(timeStep), m_algorithm(algorithm)
{
}
std::optional<QrParameters> QrParameters::parse(const QByteArray &qrCode)
{
const auto parts = uri::QrParts::parse(qrCode);
return parts ? from(*parts) : std::nullopt;
}
std::optional<QrParameters> QrParameters::parse(const QString &qrCode)
{
const auto parts = uri::QrParts::parse(qrCode);
return parts ? from(*parts) : std::nullopt;
}
std::optional<QrParameters> QrParameters::from(const uri::QrParts &parts)
{
const auto type = convertType(parts.type());
const auto algorithm = convertAlgorithm(parts.algorithm());
const auto timeStep = parseUnsigned(parts.timeStep(), 30U, positiveUpTo(std::numeric_limits<uint>::max()));
const auto tokenLength = parseUnsigned(parts.tokenLength(), 6U, [](qulonglong v) -> bool
{
return v >= 6ULL && v <= 10ULL;
});
const auto counter = parts.counter().isEmpty()
? std::optional<quint64>(0ULL)
: parseUnsigned(parts.counter(),positiveUpTo(std::numeric_limits<quint64>::max()));
const auto secret = checkNonEmptyString(parts.secret(), [](QString &v) -> bool
{
while ((v.size() % 8) != 0) {
v += QLatin1Char('=');
}
return (bool) base32::validate(v);
});
const auto name = parts.name().isEmpty()
? std::optional<QString>(QString())
: checkNonEmptyString(parts.name(), usableName());
const auto issuer = parts.issuer().isEmpty()
? std::optional<QString>(QString())
: checkNonEmptyString(parts.issuer(), usableName(QLatin1Char(':')));
return type && algorithm && timeStep && tokenLength && counter && secret && name && issuer
? std::optional<QrParameters>(QrParameters(*type, *name, *issuer, *secret, *tokenLength, *counter, *timeStep, *algorithm))
: std::nullopt;
}
void QrParameters::populate(AccountInput *input) const
{
if (!input) {
Q_ASSERT_X(input, Q_FUNC_INFO, "Input must be provided");
return;
}
input->setType(m_type);
input->setName(m_name);
input->setIssuer(m_issuer);
input->setSecret(m_secret);
input->setTokenLength(m_tokenLength);
input->setCounter(m_counter);
input->setTimeStep(m_timeStep);
input->setAlgorithm(m_algorithm);
}
}

42
src/model/qr.h Normal file
View File

@ -0,0 +1,42 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
* SPDX-FileCopyrightText: 2020 Johan Ouwerkerk <jm.ouwerkerk@gmail.com>
*/
#ifndef MODEL_QR_CODE_H
#define MODEL_QR_CODE_H
#include "input.h"
#include "../uri/uri.h"
#include <QByteArray>
#include <QObject>
#include <QString>
#include <optional>
namespace model
{
class QrParameters
{
public:
static std::optional<QrParameters> parse(const QByteArray &qrCode);
static std::optional<QrParameters> parse(const QString &qrCode);
static std::optional<QrParameters> from(const uri::QrParts &parts);
void populate(AccountInput *input) const;
private:
explicit QrParameters(AccountInput::TokenType type, const QString &name, const QString &issuer,
const QString &secret, uint tokenLength, quint64 counter, uint timeStep,
AccountInput::TOTPAlgorithm algorithm);
private:
const AccountInput::TokenType m_type;
const QString m_name;
const QString m_issuer;
const QString m_secret;
const uint m_tokenLength;
const quint64 m_counter;
const uint m_timeStep;
const AccountInput::TOTPAlgorithm m_algorithm;
};
}
#endif