Commit 8819d205 authored by Bhushan Shah's avatar Bhushan Shah 📱

Initial code for OTP client

It uses the oath-toolkit[1] provided library liboath to generate the 2FA
codes, both TOTP and HOTP based. Currently it is largely untested. From
initial rough testing it seems that auto-refreshing of code is not
working. Also button to refresh token for HOTP is also dummy at moment.

Some todo items include,

- Verify the generated oath code is correct
- Make refreshing token work
- QR code scanning
- Backup and Restore of accounts
- Clipboard support to automatically copy code.
- Encrypted storage of the secret token

This code is largely based on the authenticator-ng[2] application by the
Rodney Dawes and Michael Zanetti for the Ubuntu Touch.

[1] https://www.nongnu.org/oath-toolkit/
[2] https://github.com/dobey/authenticator-ng
parents
.flatpak-builder/*
project(otpclient)
cmake_minimum_required(VERSION 2.8.12)
set(KF5_MIN_VERSION "5.18.0")
set(QT_MIN_VERSION "5.5.0")
################# Disallow in-source build #################
if("${CMAKE_SOURCE_DIR}" STREQUAL "${CMAKE_BINARY_DIR}")
message(FATAL_ERROR "This application requires an out of source build. Please create a separate build directory.")
endif()
include(FeatureSummary)
################# set KDE specific information #################
find_package(ECM 0.0.8 REQUIRED NO_MODULE)
# where to look first for cmake modules, before ${CMAKE_ROOT}/Modules/ is checked
set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} ${ECM_MODULE_PATH} ${ECM_KDE_MODULE_DIR} "${CMAKE_SOURCE_DIR}/cmake/")
include(ECMSetupVersion)
include(ECMGenerateHeaders)
include(KDEInstallDirs)
include(KDECMakeSettings)
include(ECMPoQmTools)
include(KDECompilerSettings NO_POLICY_SCOPE)
################# Find dependencies #################
find_package(Qt5 ${QT_MIN_VERSION} REQUIRED NO_MODULE COMPONENTS Core Quick Test Gui Svg QuickControls2)
find_package(LibOath REQUIRED)
find_package(KF5Kirigami2 ${KF5_MIN_VERSION})
################# Enable C++11 features for clang and gcc #################
if(UNIX)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -std=c++0x")
endif()
################# build and install #################
add_subdirectory(src)
install(PROGRAMS org.kde.otpclient.desktop DESTINATION ${KDE_INSTALL_APPDIR})
install(FILES org.kde.otpclient.appdata.xml DESTINATION ${KDE_INSTALL_METAINFODIR})
feature_summary(WHAT ALL INCLUDE_QUIET_PACKAGES FATAL_ON_MISSING_REQUIRED_PACKAGES)
# OTP client
It uses the [oath-toolkit](https://www.nongnu.org/oath-toolkit/) provided library liboath to generate the 2FA codes, both TOTP and HOTP based. Currently it is largely untested. From initial rough testing it seems that auto-refreshing of code is not working. Also button to refresh token for HOTP is also dummy at moment.
Some todo items include,
- Verify the generated oath code is correct
- Make refreshing token work
- QR code scanning
- Backup and Restore of accounts
- Clipboard support to automatically copy code.
- Encrypted storage of the secret token
This code is largely based on the [authenticator-ng](https://github.com/dobey/authenticator-ng) application by the Rodney Dawes and Michael Zanetti for the Ubuntu Touch.
#.rst:
# FindLibOath
# ---------
#
# Try to locate the liboath library.
# If found, this will define the following variables:
#
# ``LIBOATH_FOUND``
# True if the LibOath library is available
# ``LIBOATH_INCLUDE_DIRS``
# The LibOath include directories
# ``LIBOATH_LIBRARIES``
# The LibOath libraries for linking
# ``LIBOATH_INCLUDE_DIR``
# Deprecated, use ``LIBOATH_INCLUDE_DIRS``
# ``LIBOATH_LIBRARY``
# Deprecated, use ``LIBOATH_LIBRARIES``
#
# If ``LIBOATH_FOUND`` is TRUE, it will also define the following
# imported target:
#
# ``LIBOATH::LIBOATH``
# The LIBOATH library
#
#=============================================================================
# Copyright (c) 2019 Bhushan Shah, <bshah@kde.org>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
#
# 1. Redistributions of source code must retain the copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# 3. The name of the author may not be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#=============================================================================
find_package(PkgConfig)
pkg_check_modules(PC_LIBOATH QUIET liboath)
find_path(LIBOATH_INCLUDE_DIRS
NAMES oath.h
HINTS ${PC_LIBOATH_INCLUDEDIR}
PATH_SUFFIXES liboath)
find_library(LIBOATH_LIBRARIES
NAMES oath
HINTS ${PC_LIBOATH_LIBDIR})
set(LIBOATH_INCLUDE_DIR "${LIBOATH_INCLUDE_DIRS}")
set(LIBOATH_LIBRARY "${LIBOATH_LIBRARIES}")
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(LIBOATH DEFAULT_MSG LIBOATH_LIBRARIES LIBOATH_INCLUDE_DIRS)
if(LIBOATH_FOUND AND NOT TARGET LIBOATH::LIBOATH)
add_library(LIBOATH::LIBOATH UNKNOWN IMPORTED)
set_target_properties(LIBOATH::LIBOATH PROPERTIES
IMPORTED_LOCATION "${LIBOATH_LIBRARIES}"
INTERFACE_INCLUDE_DIRECTORIES "${LIBOATH_INCLUDE_DIR}")
endif()
mark_as_advanced(LIBOATH_INCLUDE_DIRS LIBOATH_INCLUDE_DIR
LIBOATH_LIBRARIES LIBOATH_LIBRARY)
include(FeatureSummary)
set_package_properties(LIBOATH PROPERTIES
URL "http://www.nongnu.org/oath-toolkit/"
DESCRIPTION "Library for Open AuTHentication (OATH) HOTP etc support.")
This diff is collapsed.
<?xml version="1.0" encoding="utf-8"?>
<component type="desktop-application">
<id>org.kde.otpclient</id>
<name>Kirigami Example Application</name>
<summary>A short summary describing what this software is about</summary>
<metadata_license>A permissive license for this metadata, e.g. "FSFAP"</metadata_license>
<project_license>The license of this software as SPDX string, e.g. "GPL-3+"</project_license>
<developer_name>The software vendor name, e.g. "ACME Corporation"</developer_name>
<description>
<p>Multiple paragraphs of long description, describing this software component.</p>
<p>You can also use ordered and unordered lists:</p>
<ul>
<li>Feature 1</li>
<li>Feature 2</li>
</ul>
<p>Keep in mind to XML-escape characters, and that this is not HTML markup.</p>
</description>
</component>
[Desktop Entry]
Name=OTP client
Comment=My first Plasma Mobile App
Version=1.0
Exec=org.kde.otpclient
Icon=applications-development
Type=Application
Terminal=false
Categories=Qt;KDE;
{
"id": "org.kde.otpclient",
"runtime": "org.kde.Platform",
"runtime-version": "5.12",
"sdk": "org.kde.Sdk",
"command": "org.kde.otpclient",
"tags": ["nightly"],
"desktop-file-name-suffix": " (Nightly)",
"finish-args": [
"--share=ipc",
"--share=network",
"--socket=x11",
"--socket=wayland",
"--device=dri",
"--filesystem=home",
"--talk-name=org.freedesktop.Notifications"
],
"separate-locales": false,
"modules": [
{
"name": "xmlsec1",
"sources": [
{
"type": "archive",
"url": "https://www.aleksey.com/xmlsec/download/xmlsec1-1.2.27.tar.gz",
"sha256": "97d756bad8e92588e6997d2227797eaa900d05e34a426829b149f65d87118eb6"
}
]
},
{
"name": "oath-toolkit",
"sources": [
{
"type": "archive",
"url": "http://download.savannah.nongnu.org/releases/oath-toolkit/oath-toolkit-2.6.2.tar.gz",
"sha256": "b03446fa4b549af5ebe4d35d7aba51163442d255660558cd861ebce536824aa0"
},
{
"type": "patch",
"path": "flatpak/2fffce2a471f74a585939c84cce16ef3015e5d3d.diff",
"sha256": "4093d69a22af60fac339fcee22ff29c3b8418b76bc1286e5226505af884e0c21"
},
{
"type" : "shell",
"commands" : [ "autoreconf -vfi" ]
}
]
},
{
"name": "org.kde.otpclient",
"buildsystem": "cmake-ninja",
"builddir": true,
"sources": [ { "type": "dir", "path": ".", "skip": [".git"] } ]
}
]
}
set(otpclient_SRCS
main.cpp
accountmodel.cpp
account.cpp
)
qt5_add_resources(RESOURCES resources.qrc)
add_executable(org.kde.otpclient ${otpclient_SRCS} ${RESOURCES})
target_link_libraries(org.kde.otpclient Qt5::Core Qt5::Qml Qt5::Quick Qt5::Svg ${LIBOATH_LIBRARIES})
install(TARGETS org.kde.otpclient ${KF5_INSTALL_TARGETS_DEFAULT_ARGS})
/*****************************************************************************
* Copyright: 2013 Michael Zanetti <michael_zanetti@gmx.net> *
* *
* This project is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This project is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
****************************************************************************/
#include "account.h"
#include <QDebug>
#include <QDateTime>
#ifndef SIZE_MAX
#define SIZE_MAX UINT_MAX
#endif
extern "C" {
#include <liboath/oath.h>
}
Account::Account(const QUuid &id, QObject *parent) :
QObject(parent),
m_id(id),
m_counter(0),
m_timeStep(30),
m_pinLength(6)
{
m_totpTimer.setSingleShot(true);
connect(&m_totpTimer, SIGNAL(timeout()), SLOT(generate()));
}
QUuid Account::id() const
{
return m_id;
}
QString Account::name() const
{
return m_name;
}
void Account::setName(const QString &name)
{
if (m_name != name) {
m_name = name;
emit nameChanged();
}
}
Account::Type Account::type() const
{
return m_type;
}
void Account::setType(Account::Type type)
{
if (m_type != type) {
m_type = type;
// qDebug() << "setting type" << type;
emit typeChanged();
generate();
}
}
QString Account::secret() const
{
return m_secret;
}
void Account::setSecret(const QString &secret)
{
if (m_secret != secret) {
m_secret = secret;
emit secretChanged();
generate();
}
}
quint64 Account::counter() const
{
return m_counter;
}
void Account::setCounter(quint64 counter)
{
if (m_counter != counter) {
m_counter = counter;
emit counterChanged();
generate();
}
}
int Account::timeStep() const
{
return m_timeStep;
}
void Account::setTimeStep(int timeStep)
{
if (m_timeStep != timeStep) {
m_timeStep = timeStep;
emit timeStepChanged();
generate();
}
}
int Account::pinLength() const
{
return m_pinLength;
}
void Account::setPinLength(int pinLength)
{
if (m_pinLength != pinLength) {
m_pinLength = pinLength;
emit pinLengthChanged();
generate();
}
}
QString Account::otp() const
{
return m_otp;
}
qint64 Account::msecsToNext() const
{
if (m_timeStep <= 0) {
return 0;
}
qint64 now = QDateTime::currentMSecsSinceEpoch();
qint64 msecsSinceLast = now % (m_timeStep * 1000);
qint64 msecsToNext = (m_timeStep * 1000) - msecsSinceLast;
return msecsToNext;
}
void Account::next()
{
m_counter++;
// qDebug() << "emitting changed";
emit counterChanged();
generate();
}
void Account::generate()
{
if (m_secret.isEmpty()) {
// qWarning() << "No secret set. Cannot generate otp.";
return;
}
if (m_pinLength <= 0) {
// qWarning() << "Pin length is" << m_pinLength << ". Cannot generate otp.";
return;
}
if (m_type == TypeTOTP && m_timeStep <= 0) {
// qWarning() << "Time step is 0. Cannot generate totp";
return;
}
// qDebug() << "generating for account" << m_name;
QByteArray hexSecret = fromBase32(m_secret.toLatin1());
// qDebug() << "hexSecret" << hexSecret;
char code[m_pinLength];
if (m_type == TypeHOTP) {
oath_hotp_generate(hexSecret.data(), hexSecret.length(), m_counter, m_pinLength, false, OATH_HOTP_DYNAMIC_TRUNCATION, code);
} else {
oath_totp_generate(hexSecret.data(), hexSecret.length(), QDateTime::currentDateTime().toTime_t(), m_timeStep, 0, m_pinLength, code);
}
m_otp = QLatin1String(code);
// qDebug() << "Generating secret" << m_name << m_secret << m_counter << m_pinLength << m_otp << m_timeStep;
emit otpChanged();
if (m_type == TypeTOTP) {
// QTimer tends to be a wee bit too early...
// let's just add half a sec to make sure we end up in
// the current time slot and avoid restarting timers in the ui
m_totpTimer.setInterval(msecsToNext() + 500);
// qDebug() << "restarting timer for" << m_name << m_totpTimer.interval() << msecsToNext << QDateTime::currentDateTime().toMSecsSinceEpoch();
m_totpTimer.start();
}
}
QByteArray Account::fromBase32(const QByteArray &input)
{
int buffer = 0;
int bitsLeft = 0;
int count = 0;
QByteArray result;
for (int i = 0; i < input.length(); ++i) {
char ch = input.at(i);
if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' || ch == '-') {
continue;
}
buffer <<= 5;
if (ch == '0') {
ch = 'O';
} else if (ch == '1') {
ch = 'L';
} else if (ch == '8') {
ch = 'B';
}
if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z')) {
ch = (ch & 0x1F) - 1;
} else if (ch >= '2' && ch <= '7') {
ch -= '2' - 26;
} else {
return QByteArray();
}
buffer |= ch;
bitsLeft += 5;
if (bitsLeft >= 8) {
result[count++] = buffer >> (bitsLeft - 8);
bitsLeft -= 8;
}
}
return result;
}
/*****************************************************************************
* Copyright: 2013 Michael Zanetti <michael_zanetti@gmx.net> *
* *
* This project is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This project is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
****************************************************************************/
#ifndef ACCOUNT_H
#define ACCOUNT_H
#include <QObject>
#include <QUuid>
#include <QTimer>
class Account : public QObject
{
Q_OBJECT
Q_ENUMS(Type)
Q_PROPERTY(QString name READ name WRITE setName NOTIFY nameChanged)
Q_PROPERTY(Type type READ type WRITE setType NOTIFY typeChanged)
Q_PROPERTY(QString secret READ secret WRITE setSecret NOTIFY secretChanged)
Q_PROPERTY(quint64 counter READ counter WRITE setCounter NOTIFY counterChanged)
Q_PROPERTY(int timeStep READ timeStep WRITE setTimeStep NOTIFY timeStepChanged)
Q_PROPERTY(int pinLength READ pinLength WRITE setPinLength NOTIFY pinLengthChanged)
Q_PROPERTY(QString otp READ otp NOTIFY otpChanged)
public:
enum Type {
TypeHOTP,
TypeTOTP
};
explicit Account(const QUuid &id, QObject *parent = 0);
QUuid id() const;
QString name() const;
void setName(const QString &name);
Type type() const;
void setType(Type type);
QString secret() const;
void setSecret(const QString &secret);
quint64 counter() const;
void setCounter(quint64 counter);
int timeStep() const;
void setTimeStep(int timeStep);
int pinLength() const;
void setPinLength(int pinLength);
QString otp() const;
Q_INVOKABLE qint64 msecsToNext() const;
signals:
void nameChanged();
void typeChanged();
void secretChanged();
void counterChanged();
void timeStepChanged();
void pinLengthChanged();
void otpChanged();
public slots:
void generate();
void next();
private:
static QByteArray fromBase32(const QByteArray &input);
private:
QUuid m_id;
QString m_name;
Type m_type;
QString m_secret;
quint64 m_counter;
int m_timeStep;
int m_pinLength;
QString m_otp;
QTimer m_totpTimer;
};
#endif // ACCOUNT_H
/*****************************************************************************
* Copyright: 2013 Michael Zanetti <michael_zanetti@gmx.net> *
* *
* This project is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This project is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
****************************************************************************/
#include "accountmodel.h"
#include "account.h"
#include <QSettings>
#include <QStringList>
//#include <QDebug>
AccountModel::AccountModel(QObject *parent) :
QAbstractListModel(parent)
{
QSettings settings("org.kde.otpclient", "otpclient");
// qDebug() << "loading settings file:" << settings.fileName();
foreach(const QString & group, settings.childGroups()) {
// qDebug() << "found group" << group << QUuid(group).toString();
QUuid id = QUuid(group);
bool migrateAccount = false;
if (id.isNull()) {
migrateAccount = true;
id = QUuid::createUuid();
}
settings.beginGroup(group);
Account *account = new Account(id, this);
account->setName(settings.value("account").toString());
account->setType(settings.value("type", "hotp").toString() == "totp" ? Account::TypeTOTP : Account::TypeHOTP);
account->setSecret(settings.value("secret").toString());
account->setCounter(settings.value("counter").toInt());
account->setTimeStep(settings.value("timeStep").toInt());
account->setPinLength(settings.value("pinLength").toInt());
connect(account, SIGNAL(nameChanged()), SLOT(accountChanged()));
connect(account, SIGNAL(typeChanged()), SLOT(accountChanged()));
connect(account, SIGNAL(secretChanged()), SLOT(accountChanged()));
connect(account, SIGNAL(counterChanged()), SLOT(accountChanged()));
connect(account, SIGNAL(timeStepChanged()), SLOT(accountChanged()));
connect(account, SIGNAL(pinLengthChanged()), SLOT(accountChanged()));
connect(account, SIGNAL(otpChanged()), SLOT(accountChanged()));
m_accounts.append(account);
if (migrateAccount) {
settings.remove("");
storeAccount(account);
}
settings.endGroup();
}
}
int AccountModel::rowCount(const QModelIndex &parent) const
{
Q_UNUSED(parent)
return m_accounts.count();
}
QVariant AccountModel::data(const QModelIndex &index, int role) const
{
switch (role) {
case RoleName:
return m_accounts.at(index.row())->name();
case RoleType:
return m_accounts.at(index.row())->type();
case RoleSecret:
return m_accounts.at(index.row())->secret();
case RoleCounter:
return m_accounts.at(index.row())->counter();
case RoleTimeStep:
return m_accounts.at(index.row())->timeStep();
case RolePinLength:
return m_accounts.at(index.row())->pinLength();
case RoleOtp:
return m_accounts.at(index.row())->otp();
}
return QVariant();
}
Account *AccountModel::get(int index) const
{
if (index > -1 && m_accounts.count() > index) {
return m_accounts.at(index);
}
return 0;
}
Account *AccountModel::createAccount()
{
Account *account = new Account(QUuid::createUuid(), this);
beginInsertRows(QModelIndex(), m_accounts.count(), m_accounts.count());
m_accounts.append(account);
connect(account, SIGNAL(nameChanged()), SLOT(accountChanged()));
connect(account, SIGNAL(typeChanged()), SLOT(accountChanged()));
connect(account, SIGNAL(secretChanged()), SLOT(accountChanged()));
connect(account, SIGNAL(counterChanged()), SLOT(accountChanged()));
connect(account, SIGNAL(pinLengthChanged()), SLOT(accountChanged()));
connect(account, SIGNAL(otpChanged()), SLOT(accountChanged()));
storeAccount(account);
endInsertRows();
return account;
}
void AccountModel::deleteAccount(int index)
{
// qDebug() << "starting deleteAccount" << index << m_accounts.count();
beginRemoveRows(QModelIndex(), index, index);
Account *account = m_accounts.takeAt(index);
// qDebug() << "got account" << account;
QSettings settings("org.kde.otpclient", "otpclient");
settings.beginGroup(account->id().toString());
settings.remove("");
settings.endGroup();
// qDebug() << "removed from settings";
account->deleteLater();