Add support for a master key in account storage

With this change an unlock stage is introduced to loading account storage.
Key derivation parameters for a master key are recorded, and the master
password may be supplied to "unlock" the account secret(s) in storage.

This change paves the way for actually decrypting encrypted account
secrets later, and finally solving issue #6.
master
Johan Ouwerkerk 2020-02-18 19:44:01 +01:00
parent a9ed1507b2
commit 4d966c3926
32 changed files with 1352 additions and 10 deletions

View File

@ -8,4 +8,5 @@ add_subdirectory(dispatcher)
add_subdirectory(validation)
add_subdirectory(compute-jobs)
add_subdirectory(file-jobs)
add_subdirectory(keys)
add_subdirectory(storage)

View File

@ -10,3 +10,4 @@ ecm_add_test(load-accounts.cpp ${RCC_SOURCES} LINK_LIBRARIES ${Test_DEP_LIBS} TE
ecm_add_test(delete-accounts.cpp ${RCC_SOURCES} LINK_LIBRARIES ${Test_DEP_LIBS} TEST_NAME delete-accounts NAME_PREFIX account-jobs-)
ecm_add_test(save-hotp.cpp ${RCC_SOURCES} LINK_LIBRARIES ${Test_DEP_LIBS} TEST_NAME save-hotp NAME_PREFIX account-jobs-)
ecm_add_test(save-totp.cpp ${RCC_SOURCES} LINK_LIBRARIES ${Test_DEP_LIBS} TEST_NAME save-totp NAME_PREFIX account-jobs-)
ecm_add_test(request-account-password.cpp ${RCC_SOURCES} LINK_LIBRARIES ${Test_DEP_LIBS} TEST_NAME request-account-password NAME_PREFIX account-jobs-)

View File

@ -0,0 +1,328 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
* SPDX-FileCopyrightText: 2020 Johan Ouwerkerk <jm.ouwerkerk@gmail.com>
*/
#include "account/actions_p.h"
#include "../test-utils/output.h"
#include "../test-utils/spy.h"
#include <QSignalSpy>
#include <QString>
#include <QTest>
#include <QtDebug>
#include <string.h>
static QString existingPasswordIniResource(QLatin1String(":/request-account-password/existing-password.ini"));
static QString newPasswordIniResource(QLatin1String(":/request-account-password/new-password.ini"));
static QString newPasswordIniResultResource(QLatin1String(":/request-account-password/new-password-result.ini"));
class RequestAccountPasswordTest: public QObject
{
Q_OBJECT
private Q_SLOTS:
void testExistingPassword(void);
void testExistingPasswordAbort(void);
void testNewPassword(void);
void testNewPasswordAbort(void);
void testAbortBeforeRun(void);
};
void RequestAccountPasswordTest::testAbortBeforeRun(void)
{
const QString isolated(QLatin1String("abort-before-run.ini"));
QVERIFY2(test::copyResourceAsWritable(newPasswordIniResource, isolated), "accounts INI resource should be available as file");
int openCounter = 0;
const QString actualIni = test::path(isolated);
const accounts::SettingsProvider settings([&openCounter, &actualIni](const accounts::PersistenceAction &action) -> void
{
QSettings data(actualIni, QSettings::IniFormat);
openCounter++;
action(data);
});
accounts::AccountSecret secret;
QSignalSpy existingPasswordNeeded(&secret, &accounts::AccountSecret::existingPasswordNeeded);
QSignalSpy newPasswordNeeded(&secret, &accounts::AccountSecret::newPasswordNeeded);
QSignalSpy passwordAvailable(&secret, &accounts::AccountSecret::passwordAvailable);
QSignalSpy keyAvailable(&secret, &accounts::AccountSecret::keyAvailable);
QSignalSpy passwordRequestsCancelled(&secret, &accounts::AccountSecret::requestsCancelled);
accounts::RequestAccountPassword uut(settings, &secret);
QSignalSpy failed(&uut, &accounts::RequestAccountPassword::failed);
QSignalSpy unlocked(&uut, &accounts::RequestAccountPassword::unlocked);
QSignalSpy jobFinished(&uut, &accounts::RequestAccountPassword::finished);
secret.cancelRequests();
uut.run();
QVERIFY2(test::signal_eventually_emitted_once(passwordRequestsCancelled), "account secret should have signalled cancellation by now");
QVERIFY2(test::signal_eventually_emitted_once(failed), "job should signal it failed to unlock the accounts");
QVERIFY2(test::signal_eventually_emitted_once(jobFinished), "job should be finished");
QCOMPARE(openCounter, 0);
QCOMPARE(newPasswordNeeded.count(), 0);
QCOMPARE(existingPasswordNeeded.count(), 0);
QCOMPARE(passwordAvailable.count(), 0);
QCOMPARE(keyAvailable.count(), 0);
QCOMPARE(passwordRequestsCancelled.count(), 1);
QCOMPARE(failed.count(), 1);
QCOMPARE(unlocked.count(), 0);
QFile result(actualIni);
QVERIFY2(result.exists(), "accounts file should still exist");
QCOMPARE(test::slurp(actualIni), test::slurp(newPasswordIniResource));
}
void RequestAccountPasswordTest::testNewPassword(void)
{
const QString isolated(QLatin1String("supply-new-password.ini"));
QVERIFY2(test::copyResourceAsWritable(newPasswordIniResource, isolated), "accounts INI resource should be available as file");
int openCounter = 0;
const QString actualIni = test::path(isolated);
const accounts::SettingsProvider settings([&openCounter, &actualIni](const accounts::PersistenceAction &action) -> void
{
QSettings data(actualIni, QSettings::IniFormat);
openCounter++;
action(data);
});
const secrets::SecureRandom fakeRandom([](void *buf, size_t amount) -> bool
{
memset(buf, 'A', amount);
return true;
});
accounts::AccountSecret secret(fakeRandom);
QSignalSpy existingPasswordNeeded(&secret, &accounts::AccountSecret::existingPasswordNeeded);
QSignalSpy newPasswordNeeded(&secret, &accounts::AccountSecret::newPasswordNeeded);
QSignalSpy passwordAvailable(&secret, &accounts::AccountSecret::passwordAvailable);
QSignalSpy keyAvailable(&secret, &accounts::AccountSecret::keyAvailable);
QSignalSpy passwordRequestsCancelled(&secret, &accounts::AccountSecret::requestsCancelled);
accounts::RequestAccountPassword uut(settings, &secret);
QSignalSpy failed(&uut, &accounts::RequestAccountPassword::failed);
QSignalSpy unlocked(&uut, &accounts::RequestAccountPassword::unlocked);
QSignalSpy jobFinished(&uut, &accounts::RequestAccountPassword::finished);
uut.run();
QVERIFY2(test::signal_eventually_emitted_once(newPasswordNeeded), "(new) password should be asked for");
QCOMPARE(openCounter, 1);
QCOMPARE(existingPasswordNeeded.count(), 0);
QCOMPARE(failed.count(), 0);
QCOMPARE(unlocked.count(), 0);
QCOMPARE(jobFinished.count(), 0);
QString password(QLatin1String("hello, world"));
std::optional<secrets::KeyDerivationParameters> defaults = secrets::KeyDerivationParameters::create();
QVERIFY2(defaults, "should be able to construct default key derivation parameters");
QVERIFY2(secret.answerNewPassword(password, *defaults), "should be able to answer (new) password");
QVERIFY2(test::signal_eventually_emitted_once(passwordAvailable), "(new) password should be accepted");
QVERIFY2(test::signal_eventually_emitted_once(keyAvailable), "key should be derived");
QVERIFY2(test::signal_eventually_emitted_once(unlocked), "accounts should be unlocked");
QCOMPARE(openCounter, 2);
QVERIFY2(test::signal_eventually_emitted_once(jobFinished), "job should be finished");
QCOMPARE(openCounter, 2);
QCOMPARE(newPasswordNeeded.count(), 1);
QCOMPARE(existingPasswordNeeded.count(), 0);
QCOMPARE(passwordAvailable.count(), 1);
QCOMPARE(keyAvailable.count(), 1);
QCOMPARE(passwordRequestsCancelled.count(), 0);
QCOMPARE(failed.count(), 0);
QCOMPARE(unlocked.count(), 1);
QFile result(actualIni);
QVERIFY2(result.exists(), "accounts file should still exist");
QCOMPARE(test::slurp(actualIni), test::slurp(newPasswordIniResultResource));
}
void RequestAccountPasswordTest::testNewPasswordAbort(void)
{
const QString isolated(QLatin1String("abort-new-password.ini"));
QVERIFY2(test::copyResourceAsWritable(newPasswordIniResource, isolated), "accounts INI resource should be available as file");
int openCounter = 0;
const QString actualIni = test::path(isolated);
const accounts::SettingsProvider settings([&openCounter, &actualIni](const accounts::PersistenceAction &action) -> void
{
QSettings data(actualIni, QSettings::IniFormat);
openCounter++;
action(data);
});
const secrets::SecureRandom fakeRandom([](void *buf, size_t amount) -> bool
{
memset(buf, 'A', amount);
return true;
});
accounts::AccountSecret secret(fakeRandom);
QSignalSpy existingPasswordNeeded(&secret, &accounts::AccountSecret::existingPasswordNeeded);
QSignalSpy newPasswordNeeded(&secret, &accounts::AccountSecret::newPasswordNeeded);
QSignalSpy passwordAvailable(&secret, &accounts::AccountSecret::passwordAvailable);
QSignalSpy keyAvailable(&secret, &accounts::AccountSecret::keyAvailable);
QSignalSpy passwordRequestsCancelled(&secret, &accounts::AccountSecret::requestsCancelled);
accounts::RequestAccountPassword uut(settings, &secret);
QSignalSpy failed(&uut, &accounts::RequestAccountPassword::failed);
QSignalSpy unlocked(&uut, &accounts::RequestAccountPassword::unlocked);
QSignalSpy jobFinished(&uut, &accounts::RequestAccountPassword::finished);
uut.run();
QVERIFY2(test::signal_eventually_emitted_once(newPasswordNeeded), "(new) password should be asked for");
QCOMPARE(openCounter, 1);
QCOMPARE(existingPasswordNeeded.count(), 0);
QCOMPARE(failed.count(), 0);
QCOMPARE(unlocked.count(), 0);
QCOMPARE(jobFinished.count(), 0);
secret.cancelRequests();
QVERIFY2(test::signal_eventually_emitted_once(passwordRequestsCancelled), "account secret should have signalled cancellation by now");
QVERIFY2(test::signal_eventually_emitted_once(failed), "job should signal it failed to unlock the accounts");
QVERIFY2(test::signal_eventually_emitted_once(jobFinished), "job should be finished");
QCOMPARE(openCounter, 1);
QCOMPARE(newPasswordNeeded.count(), 1);
QCOMPARE(existingPasswordNeeded.count(), 0);
QCOMPARE(passwordAvailable.count(), 0);
QCOMPARE(keyAvailable.count(), 0);
QCOMPARE(passwordRequestsCancelled.count(), 1);
QCOMPARE(failed.count(), 1);
QCOMPARE(unlocked.count(), 0);
QFile result(actualIni);
QVERIFY2(result.exists(), "accounts file should still exist");
QCOMPARE(test::slurp(actualIni), test::slurp(newPasswordIniResource));
}
void RequestAccountPasswordTest::testExistingPassword(void)
{
const QString isolated(QLatin1String("supply-existing-password.ini"));
QVERIFY2(test::copyResourceAsWritable(existingPasswordIniResource, isolated), "accounts INI resource should be available as file");
int openCounter = 0;
const QString actualIni = test::path(isolated);
const accounts::SettingsProvider settings([&openCounter, &actualIni](const accounts::PersistenceAction &action) -> void
{
QSettings data(actualIni, QSettings::IniFormat);
openCounter++;
action(data);
});
accounts::AccountSecret secret;
QSignalSpy existingPasswordNeeded(&secret, &accounts::AccountSecret::existingPasswordNeeded);
QSignalSpy newPasswordNeeded(&secret, &accounts::AccountSecret::newPasswordNeeded);
QSignalSpy passwordAvailable(&secret, &accounts::AccountSecret::passwordAvailable);
QSignalSpy keyAvailable(&secret, &accounts::AccountSecret::keyAvailable);
QSignalSpy passwordRequestsCancelled(&secret, &accounts::AccountSecret::requestsCancelled);
accounts::RequestAccountPassword uut(settings, &secret);
QSignalSpy failed(&uut, &accounts::RequestAccountPassword::failed);
QSignalSpy unlocked(&uut, &accounts::RequestAccountPassword::unlocked);
QSignalSpy jobFinished(&uut, &accounts::RequestAccountPassword::finished);
uut.run();
QVERIFY2(test::signal_eventually_emitted_once(existingPasswordNeeded), "(existing) password should be asked for");
QCOMPARE(openCounter, 1);
QCOMPARE(newPasswordNeeded.count(), 0);
QCOMPARE(failed.count(), 0);
QCOMPARE(unlocked.count(), 0);
QCOMPARE(jobFinished.count(), 0);
QString password(QLatin1String("hello, world"));
QVERIFY2(secret.answerExistingPassword(password), "should be able to answer (existing) password");
QVERIFY2(test::signal_eventually_emitted_once(passwordAvailable), "(existing) password should be accepted");
QVERIFY2(test::signal_eventually_emitted_once(keyAvailable), "key should be derived");
QVERIFY2(test::signal_eventually_emitted_once(unlocked), "accounts should be unlocked");
QCOMPARE(openCounter, 2);
QVERIFY2(test::signal_eventually_emitted_once(jobFinished), "job should be finished");
QCOMPARE(openCounter, 2);
QCOMPARE(newPasswordNeeded.count(), 0);
QCOMPARE(existingPasswordNeeded.count(), 1);
QCOMPARE(passwordAvailable.count(), 1);
QCOMPARE(keyAvailable.count(), 1);
QCOMPARE(passwordRequestsCancelled.count(), 0);
QCOMPARE(failed.count(), 0);
QCOMPARE(unlocked.count(), 1);
QFile result(actualIni);
QVERIFY2(result.exists(), "accounts file should still exist");
QCOMPARE(test::slurp(actualIni), test::slurp(existingPasswordIniResource));
}
void RequestAccountPasswordTest::testExistingPasswordAbort(void)
{
const QString isolated(QLatin1String("abort-existing-password.ini"));
QVERIFY2(test::copyResourceAsWritable(existingPasswordIniResource, isolated), "accounts INI resource should be available as file");
int openCounter = 0;
const QString actualIni = test::path(isolated);
const accounts::SettingsProvider settings([&openCounter, &actualIni](const accounts::PersistenceAction &action) -> void
{
QSettings data(actualIni, QSettings::IniFormat);
openCounter++;
action(data);
});
accounts::AccountSecret secret;
QSignalSpy existingPasswordNeeded(&secret, &accounts::AccountSecret::existingPasswordNeeded);
QSignalSpy newPasswordNeeded(&secret, &accounts::AccountSecret::newPasswordNeeded);
QSignalSpy passwordAvailable(&secret, &accounts::AccountSecret::passwordAvailable);
QSignalSpy keyAvailable(&secret, &accounts::AccountSecret::keyAvailable);
QSignalSpy passwordRequestsCancelled(&secret, &accounts::AccountSecret::requestsCancelled);
accounts::RequestAccountPassword uut(settings, &secret);
QSignalSpy failed(&uut, &accounts::RequestAccountPassword::failed);
QSignalSpy unlocked(&uut, &accounts::RequestAccountPassword::unlocked);
QSignalSpy jobFinished(&uut, &accounts::RequestAccountPassword::finished);
uut.run();
QVERIFY2(test::signal_eventually_emitted_once(existingPasswordNeeded), "(existing) password should be asked for");
QCOMPARE(openCounter, 1);
QCOMPARE(newPasswordNeeded.count(), 0);
QCOMPARE(failed.count(), 0);
QCOMPARE(unlocked.count(), 0);
QCOMPARE(jobFinished.count(), 0);
secret.cancelRequests();
QVERIFY2(test::signal_eventually_emitted_once(passwordRequestsCancelled), "account secret should have signalled cancellation by now");
QVERIFY2(test::signal_eventually_emitted_once(failed), "job should signal it failed to unlock the accounts");
QVERIFY2(test::signal_eventually_emitted_once(jobFinished), "job should be finished");
QCOMPARE(openCounter, 1);
QCOMPARE(newPasswordNeeded.count(), 0);
QCOMPARE(existingPasswordNeeded.count(), 1);
QCOMPARE(passwordAvailable.count(), 0);
QCOMPARE(keyAvailable.count(), 0);
QCOMPARE(passwordRequestsCancelled.count(), 1);
QCOMPARE(failed.count(), 1);
QCOMPARE(unlocked.count(), 0);
QFile result(actualIni);
QVERIFY2(result.exists(), "accounts file should still exist");
QCOMPARE(test::slurp(actualIni), test::slurp(existingPasswordIniResource));
}
QTEST_MAIN(RequestAccountPasswordTest)
#include "request-account-password.moc"

View File

@ -0,0 +1,6 @@
[master-key]
algorithm=2
cpu=1
length=32
memory=8192
salt="MDEyMzQ1Njc4OUFCQ0RFRg=="

View File

@ -0,0 +1,2 @@
SPDX-License-Identifier: CC0-1.0
SPDX-FileCopyrightText: 2020 Johan Ouwerkerk <jm.ouwerkerk@gmail.com>

View File

@ -0,0 +1,6 @@
[master-key]
algorithm=2
cpu=3
length=32
memory=268435456
salt="QUFBQUFBQUFBQUFBQUFBQQ=="

View File

@ -0,0 +1,2 @@
SPDX-License-Identifier: CC0-1.0
SPDX-FileCopyrightText: 2020 Johan Ouwerkerk <jm.ouwerkerk@gmail.com>

View File

@ -0,0 +1,2 @@
SPDX-License-Identifier: CC0-1.0
SPDX-FileCopyrightText: 2020 Johan Ouwerkerk <jm.ouwerkerk@gmail.com>

View File

@ -13,5 +13,8 @@
<file>delete-accounts/empty-accounts.ini</file>
<file>delete-accounts/only-hotp-left.ini</file>
<file>delete-accounts/only-totp-left.ini</file>
<file>request-account-password/new-password.ini</file>
<file>request-account-password/new-password-result.ini</file>
<file>request-account-password/existing-password.ini</file>
</qresource>
</RCC>

View File

@ -0,0 +1,16 @@
#
# SPDX-License-Identifier: BSD-2-Clause
# SPDX-FileCopyrightText: 2020 Johan Ouwerkerk <jm.ouwerkerk@gmail.com>
#
set(Test_DEP_LIBS Qt5::Core Qt5::Test account_lib account_test_lib)
set(account_secret_test_SRCS
account-secret-password-flow.cpp
)
ecm_add_tests(
${account_secret_test_SRCS}
LINK_LIBRARIES ${Test_DEP_LIBS}
NAME_PREFIX account-secret-
)

View File

@ -0,0 +1,295 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
* SPDX-FileCopyrightText: 2020 Johan Ouwerkerk <jm.ouwerkerk@gmail.com>
*/
#include "account/keys.h"
#include "../test-utils/spy.h"
#include <QSignalSpy>
#include <QTest>
class PasswordFlowTest : public QObject
{
Q_OBJECT
private Q_SLOTS:
void initTestCase(void);
void supplyExistingPassword(void);
void cancelExistingPassword(void);
void supplyNewPassword(void);
void cancelNewPassword(void);
private:
QByteArray m_salt;
std::optional<secrets::KeyDerivationParameters> m_keyParams = secrets::KeyDerivationParameters::create(
crypto_secretbox_KEYBYTES, crypto_pwhash_ALG_DEFAULT, crypto_pwhash_MEMLIMIT_MIN, crypto_pwhash_OPSLIMIT_MIN
);
};
void PasswordFlowTest::initTestCase(void)
{
m_salt.resize(crypto_pwhash_SALTBYTES);
QVERIFY2(m_keyParams, "should be able to construct key derivation parameters");
}
void PasswordFlowTest::supplyNewPassword(void)
{
accounts::AccountSecret uut;
QSignalSpy existingPasswordNeeded(&uut, &accounts::AccountSecret::existingPasswordNeeded);
QSignalSpy newPasswordNeeded(&uut, &accounts::AccountSecret::newPasswordNeeded);
QSignalSpy passwordAvailable(&uut, &accounts::AccountSecret::passwordAvailable);
QSignalSpy requestsCancelled(&uut, &accounts::AccountSecret::requestsCancelled);
QSignalSpy keyAvailable(&uut, &accounts::AccountSecret::keyAvailable);
// check correct initial state is reported
QCOMPARE(uut.isStillAlive(), true);
QCOMPARE(uut.isNewPasswordRequested(), false);
QCOMPARE(uut.isExistingPasswordRequested(), false);
QCOMPARE(uut.isPasswordAvailable(), false);
QCOMPARE(uut.isKeyAvailable(), false);
QCOMPARE(uut.key(), nullptr);
// advance the state: request password
QVERIFY2(uut.requestNewPassword(), "should be able to request a (new) password");
QVERIFY2(test::signal_eventually_emitted_once(newPasswordNeeded), "request for (new) password should be signalled");
// check the state is correctly updated
QCOMPARE(uut.isStillAlive(), true);
QCOMPARE(uut.isNewPasswordRequested(), true);
QCOMPARE(uut.isExistingPasswordRequested(), false);
QCOMPARE(uut.isPasswordAvailable(), false);
QCOMPARE(uut.isKeyAvailable(), false);
QCOMPARE(uut.key(), nullptr);
QCOMPARE(newPasswordNeeded.count(), 1);
QCOMPARE(passwordAvailable.count(), 0);
QCOMPARE(existingPasswordNeeded.count(), 0);
QCOMPARE(keyAvailable.count(), 0);
QCOMPARE(requestsCancelled.count(), 0);
// advance the state: supply password
QString password(QLatin1String("hello, world"));
QVERIFY2(m_keyParams, "should be able to construct key derivation parameters");
QVERIFY2(uut.answerNewPassword(password, *m_keyParams), "(new) password should be accepted");
QVERIFY2(test::signal_eventually_emitted_once(passwordAvailable), "availability of the (new) password should be signalled");
QCOMPARE(password, QString(QLatin1String("************")));
// check the state is correctly updated
QCOMPARE(uut.isStillAlive(), true);
QCOMPARE(uut.isNewPasswordRequested(), true);
QCOMPARE(uut.isExistingPasswordRequested(), false);
QCOMPARE(uut.isPasswordAvailable(), true);
QCOMPARE(uut.isKeyAvailable(), false);
QCOMPARE(uut.key(), nullptr);
QCOMPARE(newPasswordNeeded.count(), 1);
QCOMPARE(passwordAvailable.count(), 1);
QCOMPARE(existingPasswordNeeded.count(), 0);
QCOMPARE(keyAvailable.count(), 0);
QCOMPARE(requestsCancelled.count(), 0);
// advance the state: derive the master key
QVERIFY2(uut.deriveKey(), "key derivation should succeed");
QVERIFY2(test::signal_eventually_emitted_once(keyAvailable), "availability of the master key should be signalled");
// check the state is correctly updated
QCOMPARE(uut.isStillAlive(), true);
QCOMPARE(uut.isNewPasswordRequested(), true);
QCOMPARE(uut.isExistingPasswordRequested(), false);
QCOMPARE(uut.isPasswordAvailable(), false);
QCOMPARE(uut.isKeyAvailable(), true);
QVERIFY2(uut.key(), "should have a master key by now");
QCOMPARE(newPasswordNeeded.count(), 1);
QCOMPARE(passwordAvailable.count(), 1);
QCOMPARE(existingPasswordNeeded.count(), 0);
QCOMPARE(keyAvailable.count(), 1);
QCOMPARE(requestsCancelled.count(), 0);
}
void PasswordFlowTest::cancelNewPassword(void)
{
accounts::AccountSecret uut;
QSignalSpy existingPasswordNeeded(&uut, &accounts::AccountSecret::existingPasswordNeeded);
QSignalSpy newPasswordNeeded(&uut, &accounts::AccountSecret::newPasswordNeeded);
QSignalSpy passwordAvailable(&uut, &accounts::AccountSecret::passwordAvailable);
QSignalSpy requestsCancelled(&uut, &accounts::AccountSecret::requestsCancelled);
QSignalSpy keyAvailable(&uut, &accounts::AccountSecret::keyAvailable);
// check correct initial state is reported
QCOMPARE(uut.isStillAlive(), true);
QCOMPARE(uut.isNewPasswordRequested(), false);
QCOMPARE(uut.isExistingPasswordRequested(), false);
QCOMPARE(uut.isPasswordAvailable(), false);
QCOMPARE(uut.isKeyAvailable(), false);
QCOMPARE(uut.key(), nullptr);
// advance the state: request password
QVERIFY2(uut.requestNewPassword(), "should be able to request a (new) password");
QVERIFY2(test::signal_eventually_emitted_once(newPasswordNeeded), "request for (new) password should be signalled");
// check the state is correctly updated
QCOMPARE(uut.isStillAlive(), true);
QCOMPARE(uut.isNewPasswordRequested(), true);
QCOMPARE(uut.isExistingPasswordRequested(), false);
QCOMPARE(uut.isPasswordAvailable(), false);
QCOMPARE(uut.isKeyAvailable(), false);
QCOMPARE(uut.key(), nullptr);
QCOMPARE(newPasswordNeeded.count(), 1);
QCOMPARE(passwordAvailable.count(), 0);
QCOMPARE(existingPasswordNeeded.count(), 0);
QCOMPARE(keyAvailable.count(), 0);
QCOMPARE(requestsCancelled.count(), 0);
// advance the state: cancel the request
uut.cancelRequests();
QVERIFY2(test::signal_eventually_emitted_once(requestsCancelled), "requests for (new) password should be cancelled by now");
// check the state is correctly updated
QCOMPARE(uut.isStillAlive(), false);
QCOMPARE(uut.isNewPasswordRequested(), true);
QCOMPARE(uut.isExistingPasswordRequested(), false);
QCOMPARE(uut.isPasswordAvailable(), false);
QCOMPARE(uut.isKeyAvailable(), false);
QCOMPARE(uut.key(), nullptr);
QCOMPARE(newPasswordNeeded.count(), 1);
QCOMPARE(passwordAvailable.count(), 0);
QCOMPARE(existingPasswordNeeded.count(), 0);
QCOMPARE(keyAvailable.count(), 0);
QCOMPARE(requestsCancelled.count(), 1);
}
void PasswordFlowTest::cancelExistingPassword(void)
{
accounts::AccountSecret uut;
QSignalSpy existingPasswordNeeded(&uut, &accounts::AccountSecret::existingPasswordNeeded);
QSignalSpy newPasswordNeeded(&uut, &accounts::AccountSecret::newPasswordNeeded);
QSignalSpy passwordAvailable(&uut, &accounts::AccountSecret::passwordAvailable);
QSignalSpy requestsCancelled(&uut, &accounts::AccountSecret::requestsCancelled);
QSignalSpy keyAvailable(&uut, &accounts::AccountSecret::keyAvailable);
// check correct initial state is reported
QCOMPARE(uut.isStillAlive(), true);
QCOMPARE(uut.isNewPasswordRequested(), false);
QCOMPARE(uut.isExistingPasswordRequested(), false);
QCOMPARE(uut.isPasswordAvailable(), false);
QCOMPARE(uut.isKeyAvailable(), false);
QCOMPARE(uut.key(), nullptr);
// advance the state: request password
QVERIFY2(uut.requestExistingPassword(m_salt, *m_keyParams), "should be able to request a (existing) password");
QVERIFY2(test::signal_eventually_emitted_once(existingPasswordNeeded), "request for (existing) password should be signalled");
// check the state is correctly updated
QCOMPARE(uut.isStillAlive(), true);
QCOMPARE(uut.isNewPasswordRequested(), false);
QCOMPARE(uut.isExistingPasswordRequested(), true);
QCOMPARE(uut.isPasswordAvailable(), false);
QCOMPARE(uut.isKeyAvailable(), false);
QCOMPARE(uut.key(), nullptr);
QCOMPARE(newPasswordNeeded.count(), 0);
QCOMPARE(passwordAvailable.count(), 0);
QCOMPARE(existingPasswordNeeded.count(), 1);
QCOMPARE(keyAvailable.count(), 0);
QCOMPARE(requestsCancelled.count(), 0);
// advance the state: cancel the request
uut.cancelRequests();
QVERIFY2(test::signal_eventually_emitted_once(requestsCancelled), "requests for (new) password should be cancelled by now");
// check the state is correctly updated
QCOMPARE(uut.isStillAlive(), false);
QCOMPARE(uut.isNewPasswordRequested(), false);
QCOMPARE(uut.isExistingPasswordRequested(), true);
QCOMPARE(uut.isPasswordAvailable(), false);
QCOMPARE(uut.isKeyAvailable(), false);
QCOMPARE(uut.key(), nullptr);
QCOMPARE(newPasswordNeeded.count(), 0);
QCOMPARE(passwordAvailable.count(), 0);
QCOMPARE(existingPasswordNeeded.count(), 1);
QCOMPARE(keyAvailable.count(), 0);
QCOMPARE(requestsCancelled.count(), 1);
}
void PasswordFlowTest::supplyExistingPassword(void)
{
accounts::AccountSecret uut;
QSignalSpy existingPasswordNeeded(&uut, &accounts::AccountSecret::existingPasswordNeeded);
QSignalSpy newPasswordNeeded(&uut, &accounts::AccountSecret::newPasswordNeeded);
QSignalSpy passwordAvailable(&uut, &accounts::AccountSecret::passwordAvailable);
QSignalSpy requestsCancelled(&uut, &accounts::AccountSecret::requestsCancelled);
QSignalSpy keyAvailable(&uut, &accounts::AccountSecret::keyAvailable);
// check correct initial state is reported
QCOMPARE(uut.isStillAlive(), true);
QCOMPARE(uut.isNewPasswordRequested(), false);
QCOMPARE(uut.isExistingPasswordRequested(), false);
QCOMPARE(uut.isPasswordAvailable(), false);
QCOMPARE(uut.isKeyAvailable(), false);
QCOMPARE(uut.key(), nullptr);
// advance the state: request password
QVERIFY2(uut.requestExistingPassword(m_salt, *m_keyParams), "should be able to request a (existing) password");
QVERIFY2(test::signal_eventually_emitted_once(existingPasswordNeeded), "request for (existing) password should be signalled");
// check the state is correctly updated
QCOMPARE(uut.isStillAlive(), true);
QCOMPARE(uut.isNewPasswordRequested(), false);
QCOMPARE(uut.isExistingPasswordRequested(), true);
QCOMPARE(uut.isPasswordAvailable(), false);
QCOMPARE(uut.isKeyAvailable(), false);
QCOMPARE(uut.key(), nullptr);
QCOMPARE(newPasswordNeeded.count(), 0);
QCOMPARE(passwordAvailable.count(), 0);
QCOMPARE(existingPasswordNeeded.count(), 1);
QCOMPARE(keyAvailable.count(), 0);
QCOMPARE(requestsCancelled.count(), 0);
// advance the state: supply password
QString password(QLatin1String("hello, world"));
QVERIFY2(uut.answerExistingPassword(password), "(existing) password should be accepted");
QVERIFY2(test::signal_eventually_emitted_once(passwordAvailable), "availability of the (existing) password should be signalled");
QCOMPARE(password, QString(QLatin1String("************")));
// check the state is correctly updated
QCOMPARE(uut.isStillAlive(), true);
QCOMPARE(uut.isNewPasswordRequested(), false);
QCOMPARE(uut.isExistingPasswordRequested(), true);
QCOMPARE(uut.isPasswordAvailable(), true);
QCOMPARE(uut.isKeyAvailable(), false);
QCOMPARE(uut.key(), nullptr);
QCOMPARE(newPasswordNeeded.count(), 0);
QCOMPARE(passwordAvailable.count(), 1);
QCOMPARE(existingPasswordNeeded.count(), 1);
QCOMPARE(keyAvailable.count(), 0);
QCOMPARE(requestsCancelled.count(), 0);
// advance the state: derive the master key
QVERIFY2(uut.deriveKey(), "key derivation should succeed");
QVERIFY2(test::signal_eventually_emitted_once(keyAvailable), "availability of the master key should be signalled");
// check the state is correctly updated
QCOMPARE(uut.isStillAlive(), true);
QCOMPARE(uut.isNewPasswordRequested(), false);
QCOMPARE(uut.isExistingPasswordRequested(), true);
QCOMPARE(uut.isPasswordAvailable(), false);
QCOMPARE(uut.isKeyAvailable(), true);
QVERIFY2(uut.key(), "should have a master key by now");
QCOMPARE(newPasswordNeeded.count(), 0);
QCOMPARE(passwordAvailable.count(), 1);
QCOMPARE(existingPasswordNeeded.count(), 1);
QCOMPARE(keyAvailable.count(), 1);
QCOMPARE(requestsCancelled.count(), 0);
}
QTEST_MAIN(PasswordFlowTest)
#include "account-secret-password-flow.moc"

View File

@ -8,4 +8,5 @@ qt5_add_resources(RCC_SOURCES resources/resources.qrc)
ecm_add_test(storage-object-lifecycles.cpp ${RCC_SOURCES} LINK_LIBRARIES ${Test_DEP_LIBS} TEST_NAME storage-object-lifecycles NAME_PREFIX account-)
ecm_add_test(storage-default-lifecycle.cpp ${RCC_SOURCES} LINK_LIBRARIES ${Test_DEP_LIBS} TEST_NAME storage-default-lifecycle NAME_PREFIX account-)
ecm_add_test(storage-aborted-lifecycle.cpp ${RCC_SOURCES} LINK_LIBRARIES ${Test_DEP_LIBS} TEST_NAME storage-aborted-lifecycle NAME_PREFIX account-)
ecm_add_test(hotp-counter-update.cpp ${RCC_SOURCES} LINK_LIBRARIES ${Test_DEP_LIBS} TEST_NAME storage-hotp-counter-update NAME_PREFIX account-)

View File

@ -59,8 +59,28 @@ void HotpCounterUpdateTest::testCounterUpdate(void)
QSignalSpy storageDisposed(uut, &accounts::AccountStorage::disposed);
QSignalSpy storageCleaned(uut, &accounts::AccountStorage::destroyed);
accounts::AccountSecret *secret = uut->secret();
QSignalSpy existingPasswordNeeded(secret, &accounts::AccountSecret::existingPasswordNeeded);
QSignalSpy newPasswordNeeded(secret, &accounts::AccountSecret::newPasswordNeeded);
QSignalSpy passwordAvailable(secret, &accounts::AccountSecret::passwordAvailable);
QSignalSpy keyAvailable(secret, &accounts::AccountSecret::keyAvailable);
QSignalSpy passwordRequestsCancelled(secret, &accounts::AccountSecret::requestsCancelled);
QSignalSpy secretCleaned(secret, &accounts::AccountSecret::destroyed);
// first phase: check that account objects can be loaded from storage
// expect that unlocking is scheduled automatically, so advancing the event loop should trigger the signal
QVERIFY2(test::signal_eventually_emitted_once(existingPasswordNeeded), "(existing) password should be asked by now");
QCOMPARE(newPasswordNeeded.count(), 0);
QString password(QLatin1String("password"));
secret->answerExistingPassword(password);
QVERIFY2(test::signal_eventually_emitted_once(passwordAvailable), "(existing) password should have been accepted by now");
QCOMPARE(password, QString(QLatin1String("********")));
QVERIFY2(test::signal_eventually_emitted_once(keyAvailable, 2500), "key should have been derived by now");
// expect that loading is scheduled automatically, so advancing the event loop should trigger the signal
QVERIFY2(test::signal_eventually_emitted_once(accountAdded), "sample account should be loaded by now");
QCOMPARE(accountAdded.at(0).at(0), sampleAccountName);
@ -118,7 +138,9 @@ void HotpCounterUpdateTest::testCounterUpdate(void)
uut->dispose();
QVERIFY2(test::signal_eventually_emitted_once(storageDisposed), "storage should be disposed of by now");
QVERIFY2(test::signal_eventually_emitted_once(passwordRequestsCancelled), "account secret should have signalled cancellation by now");
QVERIFY2(test::signal_eventually_emitted_once(sampleAccountCleaned), "sample account should be cleaned up by now");
QVERIFY2(test::signal_eventually_emitted_once(secretCleaned), "account secret should be cleaned up by now");
// fifth phase: check the sum-total effects

View File

@ -1,3 +1,10 @@
[master-key]
algorithm=2
cpu=1
length=32
memory=8192
salt="MDEyMzQ1Njc4OUFCQ0RFRg=="
[%7B072a645d-6c26-57cc-81eb-d9ef3b9b39e2%7D]
account=valid-hotp-sample-1
counter=1

View File

@ -1,3 +1,10 @@
[master-key]
algorithm=2
cpu=1
length=32
memory=8192
salt="MDEyMzQ1Njc4OUFCQ0RFRg=="
[%7B072a645d-6c26-57cc-81eb-d9ef3b9b39e2%7D]
account=valid-hotp-sample-1
counter=0

View File

@ -1,3 +1,10 @@
[master-key]
algorithm=2
cpu=1
length=32
memory=8192
salt="MDEyMzQ1Njc4OUFCQ0RFRg=="
[%7B534cc72e-e9ec-5e39-a1ff-9f017c9be8cc%7D]
account=valid-totp-sample-1
pinLength=8

View File

@ -0,0 +1,6 @@
[master-key]
algorithm=2
cpu=1
length=32
memory=8192
salt="MDEyMzQ1Njc4OUFCQ0RFRg=="

View File

@ -1,3 +1,10 @@
[master-key]
algorithm=2
cpu=1
length=32
memory=8192
salt="MDEyMzQ1Njc4OUFCQ0RFRg=="
[%7B072a645d-6c26-57cc-81eb-d9ef3b9b39e2%7D]
account=valid-hotp-sample-1
counter=42

View File

@ -0,0 +1,73 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
* SPDX-FileCopyrightText: 2020 Johan Ouwerkerk <jm.ouwerkerk@gmail.com>
*/
#include "account/account.h"
#include "../test-utils/output.h"
#include "../test-utils/spy.h"
#include <QSignalSpy>
#include <QString>
#include <QTest>
#include <QtDebug>
static QString testIniResource(QLatin1String("test.ini"));
class StorageAbortLifeCycleTest: public QObject
{
Q_OBJECT
private Q_SLOTS:
void initTestCase(void);
void testLifecycle(void);
};
void StorageAbortLifeCycleTest::initTestCase(void)
{
QVERIFY2(test::ensureOutputDirectory(), "output directory should be available");
QVERIFY2(test::copyResourceAsWritable(":/storage-lifecycles/starting.ini", testIniResource), "test corpus INI resource should be available as file");
}
void StorageAbortLifeCycleTest::testLifecycle(void)
{
const QString iniResource = test::path(testIniResource);
const accounts::SettingsProvider settings([&iniResource](const accounts::PersistenceAction &action) -> void
{
QSettings data(iniResource, QSettings::IniFormat);
action(data);
});
accounts::AccountStorage *uut = accounts::AccountStorage::open(settings);
QSignalSpy accountAdded(uut, &accounts::AccountStorage::added);
QSignalSpy storageDisposed(uut, &accounts::AccountStorage::disposed);
QSignalSpy storageCleaned(uut, &accounts::AccountStorage::destroyed);
accounts::AccountSecret *secret = uut->secret();
QSignalSpy existingPasswordNeeded(secret, &accounts::AccountSecret::existingPasswordNeeded);
QSignalSpy newPasswordNeeded(secret, &accounts::AccountSecret::newPasswordNeeded);
QSignalSpy passwordAvailable(secret, &accounts::AccountSecret::passwordAvailable);
QSignalSpy keyAvailable(secret, &accounts::AccountSecret::keyAvailable);
QSignalSpy passwordRequestsCancelled(secret, &accounts::AccountSecret::requestsCancelled);
QSignalSpy secretCleaned(secret, &accounts::AccountSecret::destroyed);
// first phase: expect that unlocking is scheduled automatically, so advancing the event loop should trigger the signal
QVERIFY2(test::signal_eventually_emitted_once(existingPasswordNeeded), "(existing) password should be asked by now");
QCOMPARE(newPasswordNeeded.count(), 0);
// second phase: check that disposing storage cleans up objects properly
uut->dispose();
QVERIFY2(test::signal_eventually_emitted_once(passwordRequestsCancelled), "account secret should have signalled cancellation by now");
QVERIFY2(test::signal_eventually_emitted_once(storageDisposed), "storage should be disposed of by now");
QVERIFY2(test::signal_eventually_emitted_once(secretCleaned), "account secret should be cleaned up by now");
QVERIFY2(test::signal_eventually_emitted_once(storageCleaned), "storage should be cleaned up by now");
QCOMPARE(passwordAvailable.count(), 0);
QCOMPARE(keyAvailable.count(), 0);
QCOMPARE(accountAdded.count(), 0);
}
QTEST_MAIN(StorageAbortLifeCycleTest)
#include "storage-aborted-lifecycle.moc"

View File

@ -47,8 +47,28 @@ void StorageDefaultLifeCycleTest::testLifecycle(void)
QSignalSpy storageDisposed(uut, &accounts::AccountStorage::disposed);
QSignalSpy storageCleaned(uut, &accounts::AccountStorage::destroyed);
accounts::AccountSecret *secret = uut->secret();
QSignalSpy existingPasswordNeeded(secret, &accounts::AccountSecret::existingPasswordNeeded);
QSignalSpy newPasswordNeeded(secret, &accounts::AccountSecret::newPasswordNeeded);
QSignalSpy passwordAvailable(secret, &accounts::AccountSecret::passwordAvailable);
QSignalSpy keyAvailable(secret, &accounts::AccountSecret::keyAvailable);
QSignalSpy passwordRequestsCancelled(secret, &accounts::AccountSecret::requestsCancelled);
QSignalSpy secretCleaned(secret, &accounts::AccountSecret::destroyed);
// first phase: check that account objects can be loaded from storage
// expect that unlocking is scheduled automatically, so advancing the event loop should trigger the signal
QVERIFY2(test::signal_eventually_emitted_once(existingPasswordNeeded), "(existing) password should be asked by now");
QCOMPARE(newPasswordNeeded.count(), 0);
QString password(QLatin1String("password"));
secret->answerExistingPassword(password);
QVERIFY2(test::signal_eventually_emitted_once(passwordAvailable), "(existing) password should have been accepted by now");
QCOMPARE(password, QString(QLatin1String("********")));
QVERIFY2(test::signal_eventually_emitted_once(keyAvailable, 2500), "key should have been derived by now");
// expect that loading is scheduled automatically, so advancing the event loop should trigger the signal
QVERIFY2(test::signal_eventually_emitted_once(accountAdded), "sample account should be loaded by now");
QCOMPARE(accountAdded.at(0).at(0), sampleAccountName);
@ -61,8 +81,10 @@ void StorageDefaultLifeCycleTest::testLifecycle(void)
// second phase: check that disposing storage cleans up objects properly
uut->dispose();
QVERIFY2(test::signal_eventually_emitted_once(passwordRequestsCancelled), "account secret should have signalled cancellation by now");
QVERIFY2(test::signal_eventually_emitted_once(storageDisposed), "storage should be disposed of by now");
QVERIFY2(test::signal_eventually_emitted_once(sampleAccountCleaned), "sample account should be cleaned up by now");
QVERIFY2(test::signal_eventually_emitted_once(secretCleaned), "account secret should be cleaned up by now");
QVERIFY2(test::signal_eventually_emitted_once(storageCleaned), "storage should be cleaned up by now");
}

View File

@ -57,12 +57,32 @@ void StorageLifeCyclesTest::testLifecycle(void)
QSignalSpy storageDisposed(uut, &accounts::AccountStorage::disposed);
QSignalSpy storageCleaned(uut, &accounts::AccountStorage::destroyed);
accounts::AccountSecret *secret = uut->secret();
QSignalSpy existingPasswordNeeded(secret, &accounts::AccountSecret::existingPasswordNeeded);
QSignalSpy newPasswordNeeded(secret, &accounts::AccountSecret::newPasswordNeeded);
QSignalSpy passwordAvailable(secret, &accounts::AccountSecret::passwordAvailable);
QSignalSpy keyAvailable(secret, &accounts::AccountSecret::keyAvailable);
QSignalSpy passwordRequestsCancelled(secret, &accounts::AccountSecret::requestsCancelled);
QSignalSpy secretCleaned(secret, &accounts::AccountSecret::destroyed);
// first phase: check that account objects can be loaded from storage
QCOMPARE(accountAdded.count(), 0);
QVERIFY2(uut->isNameStillAvailable(initialAccountName), "sample account name should still be available");
QVERIFY2(uut->isNameStillAvailable(addedAccountName), "new account name should still be available");
QCOMPARE(uut->accounts(), QVector<QString>());
// expect that unlocking is scheduled automatically, so advancing the event loop should trigger the signal
QVERIFY2(test::signal_eventually_emitted_once(existingPasswordNeeded), "(existing) password should be asked by now");
QCOMPARE(newPasswordNeeded.count(), 0);
QString password(QLatin1String("password"));
secret->answerExistingPassword(password);
QVERIFY2(test::signal_eventually_emitted_once(passwordAvailable), "(existing) password should have been accepted by now");
QCOMPARE(password, QString(QLatin1String("********")));
QVERIFY2(test::signal_eventually_emitted_once(keyAvailable, 2500), "key should have been derived by now");
// expect that loading is scheduled automatically, so advancing the event loop should trigger the signal
QVERIFY2(test::signal_eventually_emitted_once(accountAdded), "sample account should be loaded by now");
QCOMPARE(accountAdded.at(0).at(0), initialAccountName);
@ -164,6 +184,8 @@ void StorageLifeCyclesTest::testLifecycle(void)
QVERIFY2(!uut->contains(addedAccountName), "contains() should no longer report the new account");
QVERIFY2(uut->get(addedAccountName) == nullptr, "get() should no longer return the new account");
QVERIFY2(test::signal_eventually_emitted_once(passwordRequestsCancelled), "account secret should have signalled cancellation by now");
/*
* The disposed() signal is the hook for consuming code to know when to drop objects.
* Check that it is emitted *before* account objects are actually destroyed, i.e that the signal arrives before, and not after the fact.
@ -172,6 +194,7 @@ void StorageLifeCyclesTest::testLifecycle(void)
QCOMPARE(addedAccountCleaned.count(), 0);
QVERIFY2(test::signal_eventually_emitted_once(addedAccountCleaned), "new account should be disposed of by now");
QVERIFY2(test::signal_eventually_emitted_once(secretCleaned), "account secret should be cleaned up by now");
// fifth phase: check the sum-total effects

View File

@ -18,6 +18,18 @@
"separate-locales": false,
"modules": [
{
"name": "libsodium",
"buildsystem": "autotools",
"builddir": true,
"sources": [
{
"type": "git",
"url": "https://github.com/jedisct1/libsodium.git",
"tag": "1.0.18"
}
]
},
{
"name": "org.kde.keysmith",
"buildsystem": "cmake-ninja",

View File

@ -7,8 +7,9 @@ set(account_SRCS
account.cpp
account_p.cpp
actions_p.cpp
keys.cpp
validation.cpp
)
add_library(account_lib STATIC ${account_SRCS})
target_link_libraries(account_lib Qt5::Core base32_lib oath_lib)
target_link_libraries(account_lib Qt5::Core base32_lib oath_lib secrets_lib)

View File

@ -103,15 +103,16 @@ namespace accounts
d->remove();
}
AccountStorage::AccountStorage(const SettingsProvider &settings, QThread *worker, QObject *parent) : QObject(parent), m_dptr(new AccountStoragePrivate(settings, this, new Dispatcher(worker, this)))
AccountStorage::AccountStorage(const SettingsProvider &settings, QThread *worker, AccountSecret *secret, QObject *parent) :
QObject(parent), m_dptr(new AccountStoragePrivate(settings, secret ? secret : new AccountSecret(secrets::defaultSecureRandom, this), this, new Dispatcher(worker, this)))
{
QTimer::singleShot(0, this, &AccountStorage::load);
QTimer::singleShot(0, this, &AccountStorage::unlock);
}
AccountStorage * AccountStorage::open(const SettingsProvider &settings, QObject *parent)
AccountStorage * AccountStorage::open(const SettingsProvider &settings, AccountSecret *secret, QObject *parent)
{
QThread *worker = new QThread(parent);
AccountStorage *storage = new AccountStorage(settings, worker, parent);
AccountStorage *storage = new AccountStorage(settings, worker, secret, parent);
QObject::connect(storage, &AccountStorage::disposed, worker, &QThread::quit);
QObject::connect(worker, &QThread::finished, worker, &QThread::deleteLater);
@ -121,6 +122,16 @@ namespace accounts
return storage;
}
void AccountStorage::unlock(void)
{
Q_D(AccountStorage);
const std::function<void(RequestAccountPassword*)> handler([this](RequestAccountPassword *job) -> void
{
QObject::connect(job, &RequestAccountPassword::unlocked, this, &AccountStorage::load);
});
d->unlock(handler);
}
void AccountStorage::load(void)
{
Q_D(AccountStorage);
@ -144,6 +155,12 @@ namespace accounts
return d->get(name);
}
AccountSecret * AccountStorage::secret(void) const
{
Q_D(const AccountStorage);
return d->secret();
}
bool AccountStorage::isNameStillAvailable(const QString &name) const
{
Q_D(const AccountStorage);

View File

@ -16,6 +16,8 @@
#include <functional>
#include "keys.h"
namespace accounts
{
using PersistenceAction = std::function<void(QSettings&)>;
@ -64,12 +66,13 @@ namespace accounts
{
Q_OBJECT
public:
static AccountStorage * open(const SettingsProvider &settings, QObject *parent = nullptr);
explicit AccountStorage(const SettingsProvider &settings, QThread *thread, QObject *parent = nullptr);
static AccountStorage * open(const SettingsProvider &settings, AccountSecret *secret = nullptr, QObject *parent = nullptr);
explicit AccountStorage(const SettingsProvider &settings, QThread *thread, AccountSecret *secret = nullptr, QObject *parent = nullptr);
void removeAll(const QSet<Account*> &accounts) const;
bool isNameStillAvailable(const QString &name) const;
bool contains(const QString &name) const;
Account * get(const QString &name) const;
AccountSecret * secret(void) const;
QVector<QString> accounts(void) const;
void dispose(void);
void addHotp(const QString &name,
@ -89,6 +92,7 @@ namespace accounts
void removed(const QString name);
void disposed(void);
private Q_SLOTS:
void unlock(void);
void load(void);
void accountRemoved(void);
void handleDisposal(void);

View File

@ -316,6 +316,11 @@ namespace accounts
return m_settings;
}
AccountSecret * AccountStoragePrivate::secret(void) const
{
return m_secret;
}
void AccountStoragePrivate::removeAccounts(const QSet<QString> &accountNames)
{
if (!m_is_still_open) {
@ -381,6 +386,7 @@ namespace accounts
m_is_still_open = false;
Null *job = new Null();
m_secret->cancelRequests();
m_actions->queueAndProceed(job, [job, &handler](void) -> void
{
handler(job);
@ -408,6 +414,7 @@ namespace accounts
m_ids.remove(id);
QTimer::singleShot(0, account, &accounts::Account::deleteLater);
}
QTimer::singleShot(0, m_secret, &accounts::AccountSecret::deleteLater);
Q_EMIT q->disposed();
}
@ -478,6 +485,21 @@ namespace accounts
});
}
void AccountStoragePrivate::unlock(const std::function<void(RequestAccountPassword*)> &handler)
{
if (!m_is_still_open) {
qCDebug(logger) << "Will not attempt to unlock accounts: storage no longer open";
return;
}
qCDebug(logger) << "Requesting to unlock account storage";
RequestAccountPassword *job = new RequestAccountPassword(m_settings, m_secret);
m_actions->queueAndProceed(job, [job, &handler](void) -> void
{
handler(job);
});
}
void AccountStoragePrivate::load(const std::function<void(LoadAccounts*)> &handler)
{
if (!m_is_still_open) {
@ -528,8 +550,8 @@ namespace accounts
return m_accounts[id];
}
AccountStoragePrivate::AccountStoragePrivate(const SettingsProvider &settings, AccountStorage *storage, Dispatcher *dispatcher) :
q_ptr(storage), m_is_still_open(true), m_actions(dispatcher), m_settings(settings)
AccountStoragePrivate::AccountStoragePrivate(const SettingsProvider &settings, AccountSecret *secret, AccountStorage *storage, Dispatcher *dispatcher) :
q_ptr(storage), m_is_still_open(true), m_actions(dispatcher), m_settings(settings), m_secret(secret)
{
}

View File

@ -7,6 +7,7 @@
#include "account.h"
#include "actions_p.h"
#include "keys.h"
#include <QDateTime>
#include <QHash>
@ -77,9 +78,10 @@ namespace accounts
class AccountStoragePrivate
{
public:
explicit AccountStoragePrivate(const SettingsProvider &settings, AccountStorage *storage, Dispatcher *dispatcher);
explicit AccountStoragePrivate(const SettingsProvider &settings, AccountSecret *secret, AccountStorage *storage, Dispatcher *dispatcher);
void dispose(const std::function<void(Null*)> &handler);
void acceptDisposal(void);
void unlock(const std::function<void(RequestAccountPassword*)> &handler);
void load(const std::function<void(LoadAccounts*)> &handler);
QVector<QString> activeAccounts(void) const;
bool isStillOpen(void) const;
@ -87,6 +89,7 @@ namespace accounts
SettingsProvider settings(void) const;
bool isNameStillAvailable(const QString &name) const;
Account * get(const QString &account) const;
AccountSecret *secret(void) const;
void removeAccounts(const QSet<QString> &accountNames);
void acceptAccountRemoval(const QString &accountName);
Account * acceptHotpAccount(const QUuid &id,
@ -127,6 +130,7 @@ namespace accounts
bool m_is_still_open;
Dispatcher * const m_actions;
const SettingsProvider m_settings;
AccountSecret * m_secret;
private:
QSet<QUuid> m_ids;
QHash<QString, QUuid> m_names;

View File

@ -38,6 +38,10 @@ namespace accounts
Q_ASSERT_X(false, Q_FUNC_INFO, "should be overridden in derived classes!");
}
RequestAccountPassword::RequestAccountPassword(const SettingsProvider &settings, AccountSecret *secret) : AccountJob(), m_settings(settings), m_secret(secret), m_failed(false), m_succeeded(false)
{
}
LoadAccounts::LoadAccounts(const SettingsProvider &settings) : AccountJob(), m_settings(settings)
{
}
@ -163,6 +167,146 @@ namespace accounts
Q_EMIT finished();
}
void RequestAccountPassword::fail(void)
{
if (m_failed || m_succeeded) {
qCDebug(logger) << "Suppressing 'failure' in unlocking accounts: already handled";
return;
}
m_failed = true;
QObject::disconnect(m_secret, &AccountSecret::requestsCancelled, this, &RequestAccountPassword::fail);
QObject::disconnect(m_secret, &AccountSecret::passwordAvailable, this, &RequestAccountPassword::unlock);
Q_EMIT failed();
Q_EMIT finished();
}
void RequestAccountPassword::unlock(void)
{
if (m_succeeded || m_failed) {
qCDebug(logger) << "Suppressing 'success' in unlocking accounts: already handled";
return;
}
QObject::disconnect(m_secret, &AccountSecret::requestsCancelled, this, &RequestAccountPassword::fail);
QObject::disconnect(m_secret, &AccountSecret::passwordAvailable, this, &RequestAccountPassword::unlock);
secrets::SecureMasterKey * derived = m_secret->deriveKey();
if (!derived) {
qCInfo(logger) << "Failed to unlock storage: unable to derive secret encryption/decryption key";
m_failed = true;
Q_EMIT failed();
Q_EMIT finished();
return;
}
bool ok = false;
m_settings([this, derived, &ok](QSettings &settings) -> void
{
if (!settings.isWritable()) {
qCWarning(logger) << "Unable to save account secret key parameters: storage not writable";
return;
}
const secrets::KeyDerivationParameters params = derived->params();
QString encodedSalt = QString::fromUtf8(derived->salt().toBase64(QByteArray::Base64Encoding));
settings.beginGroup("master-key");
settings.setValue("salt", encodedSalt);
settings.setValue("cpu", params.cpuCost());
settings.setValue("memory", (quint64) params.memoryCost());
settings.setValue("algorithm", params.algorithm());
settings.setValue("length", params.keyLength());
settings.endGroup();
ok = true;
});
if (ok) {
qCInfo(logger) << "Successfully unlocked storage";
m_succeeded = true;
Q_EMIT unlocked();
} else {
qCInfo(logger) << "Failed to unlock storage: unable to store parameters";
m_failed = true;
Q_EMIT failed();
}
Q_EMIT finished();
}
void RequestAccountPassword::run(void)
{
if (!m_secret) {
qCDebug(logger) << "Unable to request accounts password: no account secret object";
m_failed = true;
Q_EMIT failed();
Q_EMIT finished();
return;
}
QObject::connect(m_secret, &AccountSecret::passwordAvailable, this, &RequestAccountPassword::unlock);
QObject::connect(m_secret, &AccountSecret::requestsCancelled, this, &RequestAccountPassword::fail);
if (!m_secret->isStillAlive()) {
qCDebug(logger) << "Unable to request accounts password: account secret marked for death";
fail();
return;
}
bool ok = false;
m_settings([this, &ok](QSettings &settings) -> void
{
if (!settings.isWritable()) {
qCWarning(logger) << "Unable to request password for accounts: storage not writable";
return;
}
QStringList groups = settings.childGroups();
if (!groups.contains(QLatin1String("master-key"))) {
qCInfo(logger) << "No key derivation parameters found: requesting 'new' password for accounts";
ok = m_secret->requestNewPassword();
return;
}
settings.beginGroup("master-key");
QByteArray salt;
quint64 cpuCost = 0ULL;
quint64 keyLength = 0ULL;
size_t memoryCost = 0ULL;
int algorithm = settings.value("algorithm").toInt(&ok);
if (ok) {
ok = false;
keyLength = settings.value("length").toULongLong(&ok);
}
if (ok) {
ok = false;
cpuCost = settings.value("cpu").toULongLong(&ok);
}
if (ok) {
ok = false;
memoryCost = settings.value("memory").toULongLong(&ok);
}
if (ok) {
QByteArray encodedSalt = settings.value("salt").toString().toUtf8();
salt = QByteArray::fromBase64(encodedSalt, QByteArray::Base64Encoding);
ok = secrets::SecureMasterKey::validate(salt);
}
settings.endGroup();
std::optional<secrets::KeyDerivationParameters> params = secrets::KeyDerivationParameters::create(keyLength, algorithm, memoryCost, cpuCost);
if (!ok || !params || !secrets::SecureMasterKey::validate(*params)) {
qCDebug(logger) << "Unable to request 'existing' password: invalid salt or key derivation parameters";
return;
}
qCInfo(logger) << "Requesting 'existing' password for accounts";
ok = m_secret->requestExistingPassword(salt, *params);
});
if (!ok) {
qCInfo(logger) << "Unable to unlock storage: failed to request password for accounts";
fail();
}
}
void LoadAccounts::run(void)
{
const PersistenceAction act([this](QSettings &settings) -> void

View File

@ -17,6 +17,8 @@
#include <functional>
#include "keys.h"
namespace accounts
{
class AccountJob: public QObject
@ -39,6 +41,26 @@ namespace accounts
void run(void) override;
};
class RequestAccountPassword: public AccountJob
{
Q_OBJECT
public:
explicit RequestAccountPassword(const SettingsProvider &settings, AccountSecret *secret);
void run(void) override;
private Q_SLOTS:
void fail(void);
void unlock(void);
Q_SIGNALS:
void unlocked(void);
void failed(void);
private:
const SettingsProvider m_settings;
AccountSecret * m_secret;
private:
bool m_failed;
bool m_succeeded;
};
class LoadAccounts: public AccountJob
{
Q_OBJECT

224
src/account/keys.cpp Normal file
View File

@ -0,0 +1,224 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
* SPDX-FileCopyrightText: 2020 Johan Ouwerkerk <jm.ouwerkerk@gmail.com>
*/
#include "keys.h"
#include "../logging_p.h"
#include <string.h>
KEYSMITH_LOGGER(logger, ".accounts.keys")
namespace accounts
{
AccountSecret::AccountSecret(const secrets::SecureRandom &random, QObject *parent) :
QObject(parent), m_stillAlive(true), m_newPassword(false), m_passwordRequested(false), m_random(random), m_salt(std::nullopt), m_key(nullptr), m_password(nullptr), m_keyParams(std::nullopt)
{
}
void AccountSecret::cancelRequests(void)
{
if (!m_stillAlive) {
qCDebug(logger) << "Ignoring cancellation request: account secret is marked for death";
return;
}
m_stillAlive = false;
Q_EMIT requestsCancelled();
}
bool AccountSecret::requestNewPassword(void)
{
if (!m_stillAlive) {
qCDebug(logger) << "Ignoring request for 'new' password: account secret is marked for death";
return false;
}
if (m_passwordRequested) {
qCDebug(logger) << "Ignoring request for 'new' password: conflicting or duplicate request";
return false;
}
qCDebug(logger) << "Emitting request for 'new' password";
m_passwordRequested = true;
m_newPassword = true;
Q_EMIT newPasswordNeeded();
return true;
}
bool AccountSecret::requestExistingPassword(const QByteArray& salt, const secrets::KeyDerivationParameters &keyParams)
{
if (!m_stillAlive) {
qCDebug(logger) << "Ignoring request for 'existing' password: account secret is marked for death";
return false;
}
if (m_passwordRequested) {
qCDebug(logger) << "Ignoring request for 'existing' password: conflicting or duplicate request";
return false;
}
if (!secrets::SecureMasterKey::validate(keyParams)) {
qCDebug(logger) << "Unable to request 'existing' password: invalid key derivation parameters";
return false;
}
if (!secrets::SecureMasterKey::validate(salt)) {
qCDebug(logger) << "Unable to request 'existing' password: invalid salt";
return false;
}
qCDebug(logger) << "Emitting request for 'existing' password";
m_passwordRequested = true;
m_newPassword = false;
m_keyParams.emplace(keyParams);
m_salt.emplace(salt);
Q_EMIT existingPasswordNeeded();
return true;
}
bool AccountSecret::acceptPassword(QString &password, bool answerMatchesRequest)
{
QByteArray passwordBytes;
if (!m_stillAlive) {
qCDebug(logger) << "Ignoring password: account secret is marked for death";
password.fill(QLatin1Char('*'), -1);
return false;
}
if (!m_passwordRequested) {
qCDebug(logger) << "Ignoring password: was not requested";
password.fill(QLatin1Char('*'), -1);
return false;
}
if (m_key || m_password) {
qCDebug(logger) << "Ignoring password: duplicate/conflicting password";
password.fill(QLatin1Char('*'), -1);
return false;
}
if (!answerMatchesRequest) {
qCDebug(logger) << "Ignoring password: wrong answer function used for the request";
password.fill(QLatin1Char('*'), -1);
return false;
}
/*
* This is still unfortunate: no idea how many (partial) copies toUtf8() makes.
* I.e. no idea how many (partial) copies of the secret wind up floating around in memory.
*/
passwordBytes = password.toUtf8();
m_password.reset(secrets::SecureMemory::allocate((size_t) passwordBytes.size()));
if (m_password) {
qCDebug(logger) << "Accepted password for account secrets";
memcpy(m_password->data(), passwordBytes.constData(), m_password->size());
} else {
qCDebug(logger) << "Failed to accept password for account secrets";
}
/*
* Try and overwrite known copies of the password/secret (these are redundant now...)
*/
passwordBytes.fill('\0', -1);
password.fill(QLatin1Char('*'), -1);
return m_password;
}
bool AccountSecret::answerExistingPassword(QString &password)
{
bool result = acceptPassword(password, m_keyParams && m_salt);
if (result) {
Q_EMIT passwordAvailable();
}
return result;
}
bool AccountSecret::answerNewPassword(QString &password, const secrets::KeyDerivationParameters &keyParams)
{
if (!secrets::SecureMasterKey::validate(keyParams)) {
qCDebug(logger) << "Unable to accept 'existing' password: invalid key derivation parameters";
password.fill(QLatin1Char('*'), -1);
return false;
}
bool result = acceptPassword(password, !m_keyParams && !m_salt);
if (result) {
m_keyParams.emplace(keyParams);
Q_EMIT passwordAvailable();
}
return result;
}
bool AccountSecret::isStillAlive(void) const
{
return m_stillAlive;
}
bool AccountSecret::isNewPasswordRequested(void) const
{
return m_passwordRequested && m_newPassword;
}
bool AccountSecret::isExistingPasswordRequested(void) const
{
return m_passwordRequested && !m_newPassword;
}
bool AccountSecret::isKeyAvailable(void) const
{
return m_stillAlive && m_key;
}
bool AccountSecret::isPasswordAvailable(void) const
{
return m_stillAlive && m_password;
}
secrets::SecureMasterKey * AccountSecret::deriveKey(void)
{
if (!m_stillAlive) {
qCDebug(logger) << "Ignoring request to derive encryption/decryption key: account secret is marked for death";
m_password.reset(nullptr);
return nullptr;
}
if (m_key) {
qCDebug(logger) << "Ignoring request to derive encryption/decryption key: duplicate request";
m_password.reset(nullptr);
return nullptr;
}
if (!m_passwordRequested || !m_keyParams || !m_password) {
qCDebug(logger) << "Ignoring request to derive encryption/decryption key: passwor or key derivation parameters not available";
m_password.reset(nullptr);
return nullptr;
}
m_key.reset(m_salt
? secrets::SecureMasterKey::derive(m_password.data(), *m_keyParams, *m_salt, m_random)
: secrets::SecureMasterKey::derive(m_password.data(), *m_keyParams, m_random)
);
if (!m_key) {
qCDebug(logger) << "Failed to derive encryption/decryption key for account secrets";
m_password.reset(nullptr);
return nullptr;
}
qCDebug(logger) << "Successfully derived encryption/decryption key for account secrets";
m_salt.emplace(m_key->salt());
m_password.reset(nullptr);
Q_EMIT keyAvailable();
return m_key.data();
}
secrets::SecureMasterKey * AccountSecret::key(void) const
{
return m_stillAlive && m_key ? m_key.data() : nullptr;
}
}

55
src/account/keys.h Normal file
View File

@ -0,0 +1,55 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
* SPDX-FileCopyrightText: 2020 Johan Ouwerkerk <jm.ouwerkerk@gmail.com>
*/
#ifndef ACCOUNTS_KEYS_H
#define ACCOUNTS_KEYS_H
#include <QByteArray>
#include <QObject>
#include "../secrets/secrets.h"
namespace accounts
{
class AccountSecret : public QObject
{
Q_OBJECT
Q_SIGNALS:
void newPasswordNeeded(void);
void existingPasswordNeeded(void);
void passwordAvailable(void);
void keyAvailable(void);
void requestsCancelled(void);
public:
AccountSecret(const secrets::SecureRandom &random = secrets::defaultSecureRandom, QObject *parent = nullptr);
void cancelRequests(void);
bool requestNewPassword(void);
bool requestExistingPassword(const QByteArray& salt, const secrets::KeyDerivationParameters &keyParams);
bool answerExistingPassword(QString &password);
bool answerNewPassword(QString &password, const secrets::KeyDerivationParameters &keyParams);
secrets::SecureMasterKey * deriveKey(void);
secrets::SecureMasterKey * key(void) const;
bool isStillAlive(void) const;
bool isNewPasswordRequested(void) const;
bool isExistingPasswordRequested(void) const;
bool isKeyAvailable(void) const;
bool isPasswordAvailable(void) const;
private:
bool acceptPassword(QString &password, bool answerMatchesRequest);
private:
bool m_stillAlive;
bool m_newPassword;
bool m_passwordRequested;
const secrets::SecureRandom m_random;
std::optional<QByteArray> m_salt;
QScopedPointer<secrets::SecureMasterKey> m_key;
QScopedPointer<secrets::SecureMemory> m_password;
std::optional<secrets::KeyDerivationParameters> m_keyParams;
};
}
#endif