From a813810dfe19f31db8a02dc59b502a70b298359a Mon Sep 17 00:00:00 2001 From: Johan Ouwerkerk Date: Fri, 27 Dec 2019 18:14:22 +0100 Subject: [PATCH] Rewrite Account model(s) as a separate (sub)module within Keysmith, layered on top of the new Account (storage) module. This fixes the model part in issue #2 --- autotests/CMakeLists.txt | 1 + autotests/model/CMakeLists.txt | 8 ++ autotests/model/milliseconds-left-for-token.cpp | 62 ++++++++ src/CMakeLists.txt | 1 + src/model/CMakeLists.txt | 11 ++ src/model/accounts.cpp | 181 ++++++++++++++++++++++++ src/model/accounts.h | 80 +++++++++++ 7 files changed, 344 insertions(+) create mode 100644 autotests/model/CMakeLists.txt create mode 100644 autotests/model/milliseconds-left-for-token.cpp create mode 100644 src/model/CMakeLists.txt create mode 100644 src/model/accounts.cpp create mode 100644 src/model/accounts.h diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt index 664f628..1bbe36b 100644 --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -2,6 +2,7 @@ include_directories(BEFORE ../src) add_subdirectory(base32) add_subdirectory(account) +add_subdirectory(model) add_subdirectory(validators) set(Account_UUT_SRCS ../src/account.cpp) diff --git a/autotests/model/CMakeLists.txt b/autotests/model/CMakeLists.txt new file mode 100644 index 0000000..3f7b4c9 --- /dev/null +++ b/autotests/model/CMakeLists.txt @@ -0,0 +1,8 @@ +# +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2020 Johan Ouwerkerk +# + +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-) diff --git a/autotests/model/milliseconds-left-for-token.cpp b/autotests/model/milliseconds-left-for-token.cpp new file mode 100644 index 0000000..2e3c0c5 --- /dev/null +++ b/autotests/model/milliseconds-left-for-token.cpp @@ -0,0 +1,62 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 2020 Johan Ouwerkerk + */ +#include "model/accounts.h" + +#include +#include +#include + +#include + +class MillisecondsLeftForTokenTest: public QObject +{ + Q_OBJECT +private Q_SLOTS: + void testMillisecondsLeftForToken(void); + void testMillisecondsLeftForToken_data(void); +}; + +static void define_test_case(const char *testCase, const QDateTime &epoch, uint timeStep, qint64 now, qint64 left) +{ + QTest::newRow(qPrintable(QLatin1String(testCase))) << epoch << timeStep << now << left; +} + +void MillisecondsLeftForTokenTest::testMillisecondsLeftForToken(void) +{ + QFETCH(QDateTime, epoch); + QFETCH(uint, timeStep); + QFETCH(qint64, now); + + const std::function clock([now](void) -> qint64 + { + return now; + }); + + QTEST(model::millisecondsLeftForToken(epoch, timeStep, clock), "left"); +} + +void MillisecondsLeftForTokenTest::testMillisecondsLeftForToken_data(void) +{ + QTest::addColumn("epoch"); + QTest::addColumn("timeStep"); + QTest::addColumn("now"); + QTest::addColumn("left"); + + define_test_case("at the epoch itself", QDateTime::fromMSecsSinceEpoch(0), 30U, 0LL, 30LL * 1000LL); + + const QString withLeftToGo(QLatin1String("with %1ms left to go")); + for (qint64 left = 1000LL; left > 0; --left) { + QTest::newRow(qPrintable(withLeftToGo.arg(left))) << QDateTime::fromMSecsSinceEpoch(0) << 1U << 1000LL - left << left; + } + + define_test_case("at the start of 'next' token", QDateTime::fromMSecsSinceEpoch(0), 30U, 30LL * 1000LL, 30LL * 1000LL); + define_test_case("using a non-standard epoch (in the past)", QDateTime::fromMSecsSinceEpoch(15LL * 1000LL), 30U, 30LL * 1000LL, 15LL * 1000LL); + + define_test_case("using a non-standard epoch (in the future)", QDateTime::fromMSecsSinceEpoch(25LL * 1000LL), 30U, 0LL, 25LL * 1000LL); +} + +QTEST_APPLESS_MAIN(MillisecondsLeftForTokenTest) + +#include "milliseconds-left-for-token.moc" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 87c5525..d5b767d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,5 +1,6 @@ add_subdirectory(base32) add_subdirectory(account) +add_subdirectory(model) add_subdirectory(validators) set(keysmith_SRCS diff --git a/src/model/CMakeLists.txt b/src/model/CMakeLists.txt new file mode 100644 index 0000000..a5f25c6 --- /dev/null +++ b/src/model/CMakeLists.txt @@ -0,0 +1,11 @@ +# +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2020 Johan Ouwerkerk +# + +set(model_SRCS + accounts.cpp +) + +add_library(model_lib STATIC ${model_SRCS}) +target_link_libraries(model_lib Qt5::Core Qt5::Qml Qt5::Gui account_lib) diff --git a/src/model/accounts.cpp b/src/model/accounts.cpp new file mode 100644 index 0000000..2315234 --- /dev/null +++ b/src/model/accounts.cpp @@ -0,0 +1,181 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 2020 Johan Ouwerkerk + */ +#include "accounts.h" + +#include + +namespace model +{ + qint64 millisecondsLeftForToken(const QDateTime &epoch, uint timeStep, const std::function &clock) + { + QDateTime now = QDateTime::fromMSecsSinceEpoch(clock()); + if (epoch.isValid() && now.isValid() && timeStep > 0) { + /* + * Avoid integer overflow by casting to the wider type first before multiplying. + * Not likely to happen 'in the wild', but good practice nevertheless + */ + qint64 step = ((qint64) timeStep) * 1000LL; + + qint64 diff = epoch.msecsTo(now); + + /* + * Compensate for the fact that % operator is not the same as mathemtical mod in case diff is negative. + * diff is negative when the given epoch is in the 'future' compared to the current clock value. + */ + return diff < 0 ? - (diff % step) : step - (diff % step); + } + // TODO: warn if not + + return -1; + } + + AccountView::AccountView(accounts::Account *model, QObject *parent) : QObject(parent), m_model(model) + { + QObject::connect(model, &accounts::Account::tokenChanged, this, &AccountView::tokenChanged); + QObject::connect(this, &AccountView::remove, model, &accounts::Account::remove); + QObject::connect(this, &AccountView::recompute, model, &accounts::Account::recompute); + QObject::connect(this, &AccountView::advanceCounter, model, &accounts::Account::advanceCounter); + QObject::connect(this, &AccountView::setCounter, model, &accounts::Account::setCounter); + } + + bool AccountView::isHotp(void) const + { + return m_model->algorithm() == accounts::Account::Hotp; + } + + bool AccountView::isTotp(void) const + { + return m_model->algorithm() == accounts::Account::Totp; + } + + QString AccountView::name(void) const + { + return m_model->name(); + } + + QString AccountView::token(void) const + { + return m_model->token(); + } + + quint64 AccountView::counter(void) const + { + return m_model->counter(); + } + + uint AccountView::timeStep(void) const + { + return m_model->timeStep(); + } + + qint64 AccountView::millisecondsLeftForToken(void) const + { + if (isTotp()) { + return model::millisecondsLeftForToken(m_model->epoch(), m_model->timeStep()); + } + // TODO: warn if not + return -1; + } + + SimpleAccountListModel::SimpleAccountListModel(accounts::AccountStorage *storage, QObject *parent) : QAbstractListModel(parent), m_storage(storage), m_index(QVector()) + { + QObject::connect(storage, &accounts::AccountStorage::added, this, &SimpleAccountListModel::added); + QObject::connect(storage, &accounts::AccountStorage::removed, this, &SimpleAccountListModel::removed); + + beginResetModel(); + for (const QString &name : m_storage->accounts()) { + accounts::Account * existingAccount = m_storage->get(name); + if (existingAccount) { + m_index.append(name); + m_accounts[name] = existingAccount; + existingAccount->recompute(); + } + // TODO: warn if not + } + endResetModel(); + } + + void SimpleAccountListModel::addTotp(const QString &account, const QString &secret, uint timeStep, int tokenLength) + { + m_storage->addTotp(account, secret, timeStep, tokenLength); + } + + void SimpleAccountListModel::addHotp(const QString &account, const QString &secret, quint64 counter, int tokenLength) + { + m_storage->addHotp(account, secret, counter, tokenLength); + } + + QHash SimpleAccountListModel::roleNames(void) const + { + QHash roles; + roles[NonStandardRoles::AccountRole] = "account"; + return roles; + } + + QVariant SimpleAccountListModel::data(const QModelIndex &account, int role) const + { + if (!account.isValid()) { + // TODO warn about this + return QVariant(); + } + + int accountIndex = account.row(); + if (accountIndex < 0 || m_index.size() < accountIndex) { + // TODO warn about this + return QVariant(); + } + + if (role == NonStandardRoles::AccountRole) { + const QString accountName = m_index.at(accountIndex); + accounts::Account * model = m_accounts.value(accountName, nullptr); + if (model) { + // assume QML ownership: don't worry about object lifecycle + return QVariant::fromValue(new AccountView(model)); + } + } + + // TODO warn about this + return QVariant(); + } + + int SimpleAccountListModel::rowCount(const QModelIndex &parent) const + { + Q_UNUSED(parent) + return m_index.size(); + } + + void SimpleAccountListModel::added(const QString &account) + { + accounts::Account * newAccount = m_storage->get(account); + if (newAccount) { + if (m_accounts.contains(account)) { + //TODO warn about this + removed(account); + } + + int accountIndex = m_index.size(); + beginInsertRows(QModelIndex(), accountIndex, accountIndex); + m_index.append(account); + m_accounts[account] = newAccount; + newAccount->recompute(); + endInsertRows(); + } + // TODO: warn if not + } + + void SimpleAccountListModel::removed(const QString &account) + { + int accountIndex = m_index.indexOf(account); + if (accountIndex >= 0) { + beginRemoveRows(QModelIndex(), accountIndex, accountIndex); + m_index.remove(accountIndex); + + m_accounts.remove(account); + endRemoveRows(); + } + // TODO: warn if not + } + +} diff --git a/src/model/accounts.h b/src/model/accounts.h new file mode 100644 index 0000000..e3d9253 --- /dev/null +++ b/src/model/accounts.h @@ -0,0 +1,80 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 2020 Johan Ouwerkerk + */ +#ifndef MODEL_ACCOUNTS_H +#define MODEL_ACCOUNTS_H + +#include "../account/account.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace model +{ + qint64 millisecondsLeftForToken(const QDateTime &epoch, uint timeStep, const std::function &clock = &QDateTime::currentMSecsSinceEpoch); + + class AccountView : public QObject + { + Q_OBJECT + Q_PROPERTY(QString name READ name NOTIFY never) + Q_PROPERTY(QString token READ token NOTIFY tokenChanged) + Q_PROPERTY(quint64 counter READ counter NOTIFY tokenChanged); + Q_PROPERTY(uint timeStep READ timeStep NOTIFY never); + Q_PROPERTY(bool isHotp READ isHotp NOTIFY never); + Q_PROPERTY(bool isTotp READ isTotp NOTIFY never); + public: + explicit AccountView(accounts::Account *model, QObject *parent = nullptr); + QString name(void) const; + QString token(void) const; + uint timeStep(void) const; + quint64 counter(void) const; + bool isHotp(void) const; + bool isTotp(void) const; + Q_INVOKABLE qint64 millisecondsLeftForToken(void) const; + Q_SIGNALS: + void never(void); + void tokenChanged(void); + void remove(void); + void recompute(void); + void advanceCounter(quint64 by = 1ULL); + void setCounter(quint64 value); + private: + accounts::Account * const m_model; + }; + + class SimpleAccountListModel: public QAbstractListModel + { + Q_OBJECT + public: + enum NonStandardRoles { + AccountRole = Qt::ItemDataRole::UserRole + }; + Q_ENUM(NonStandardRoles) + public: + explicit SimpleAccountListModel(accounts::AccountStorage *storage, QObject *parent = nullptr); + Q_INVOKABLE void addTotp(const QString &account, const QString &secret, uint timeStep, int tokenLength); + Q_INVOKABLE void addHotp(const QString &account, const QString &secret, quint64 counter, int tokenLength); + Q_INVOKABLE int rowCount(const QModelIndex &parent = QModelIndex()) const override; + Q_INVOKABLE QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash roleNames(void) const override; + private Q_SLOTS: + + void added(const QString &account); + void removed(const QString &removed); + private: + accounts::AccountStorage * const m_storage; + private: + QVector m_index; + QHash m_accounts; + }; +} + +Q_DECLARE_METATYPE(model::AccountView *); + +#endif