diff --git a/README.md b/README.md index c96991f..e99a4c3 100644 --- a/README.md +++ b/README.md @@ -2,33 +2,31 @@ Offline captcha without any file system or database storage. -Dependencies: Qt5 and OpenSSL. - -![Cover](https://raw.githubusercontent.com/ZeroStorageCaptcha/cpp-lib/main/pics/cover.png) +Dependency: Qt5. ## Inspiration -Captcha is required in many public projects to protect against spammers and similar unwanted activity. +Captcha is required in many public projects to protect against spammers and similar automated unwanted activity. As practice shows, developers are accustomed to using external services even to use fonts (!). Obviously, implementing bot protection is more complicated than storing fonts or style sheets locally, which is why most developers turn to suck-to-free corporations. -The goal of Zero storage captcha is to make it easy to use a locally generated captcha picture without having to store the answer. -This technology allows any project to have high-quality and ethical captcha without spending VPS disk space. +The goal of Zero Storage Captcha is to make it easy to use a locally generated captcha picture without having to store the answer. +This technology allows any project to have high-quality and ethical captcha without spending VPS disk space (CPU only yep). ## How it works When generating a captcha, the user receives a picture and a token. -The token is a cryptographic key to verify the correctness of the answer. It is created based on: +The token is a string key to verify the correctness of the answer. It is created based on: -1) Captcha answer -2) Hash SHA256 -3) Session signing key X25519 -4) Time marker +BASE64( MD5_HASH( CAPTCHA_ANSWER + TIME_TOKEN + CAPTCHA_ID + SESSION_KEY ) ) + "_" + CAPTCHA_ID -The time marker (time token) changes every one and a half minutes, due to which the same answer after -a few minutes has a completely new token to check the truth. +- TIME_TOKEN - temporary marker for limiting captcha life circle; +- CAPTCHA_ID - size_t validation key for concrete captcha; +- SESSION_KEY - random run-time session string for unique hash value. + +Regular captcha token looks like this: `QyhnRNJolLJxnJaSqzQVww_1`. The user, along with the picture, must provide a verification token, which he will report to the server along with the response to the picture. This can be implemented both through javascript and when generating html pages using the templating method. @@ -39,11 +37,11 @@ a few seconds before the time token change. Due to this architecture, the lifetime of each captcha ranges from 1.5 to 3 minutes, after which the verification token will always show failure. -To make it impossible to use one captcha twice, the used verification token gets into a special cache, -where it is stored for several minutes of the life cycle of the token. -The token is considered used after the first validation check. +To make it impossible to use one captcha twice, the used verification captcha id gets into a special cache, +where it is stored for several minutes of the life cycle of the concrete captcha token. +The token is considered used after the first validation check. Storing captcha id is very cheap: the id has a weight of 8 bytes (for a 64-bit system). For example, to store a million solved captchas at one time would need less than 8 MB of RAM. -Session signing key generated at every start of app (at first call to ZeroStorageCaptchaCrypto::KeyHolder class). +To guarantee the uniqueness of captcha tokens, when generating the first captcha, creates a SESSION_KEY (random string), which takes part in creating the control hash that forms the token. For the generation of the hash is chosen MD5, as it is a productive algorithm, which is sufficiently reliable in the context of Zero Storage Captcha. -Check `examples` folder to see C++ interface or if your project not in C++, -you can use Zero storage captcha as separate cross-platform local [service](https://github.com/ZeroStorageCaptcha/api-daemon). +Check `examples` folder to see C++ interface or if your project not in C++, also +you can use Zero Storage Captcha as separate cross-platform local [service](https://github.com/ZeroStorageCaptcha/api-daemon). diff --git a/examples/example1.cpp b/examples/example1.cpp index 37c741e..1d40740 100644 --- a/examples/example1.cpp +++ b/examples/example1.cpp @@ -1,4 +1,4 @@ -// 2022 (c) GPLv3, acetone at i2pmail.org +// GPLv3 (c) acetone, 2022 // Zero Storage Captcha example #include "zerostoragecaptcha.h" @@ -15,14 +15,20 @@ int main(int argc, char *argv[]) // "Environment=QT_QPA_PLATFORM=offscreen" in systemd service ([Service] section) QApplication a(argc, argv); - ZeroStorageCaptchaCrypto::KeyHolder::setCaseSensitive(true); - auto captcha = ZeroStorageCaptcha::getCaptcha(); - qInfo() << captcha.token() << captcha.answer(); - QFile f("captcha.png"); - if (not f.open(QIODevice::WriteOnly)) return 1; - f.write(captcha.picture()); - f.close(); - qInfo() << "Validation: " << ZeroStorageCaptcha::validate(captcha.answer(), captcha.token()); + ZeroStorageCaptcha::setCaseSensitive(true); + ZeroStorageCaptcha::setNumbersOnlyMode(false); // false by default, just example + + ZeroStorageCaptcha c; + c.generateAnswer(); // create captcha text + c.render(); // create picture + QFile pic("c.png"); + if (not pic.open(QIODevice::WriteOnly)) return 1; + pic.write(c.picturePng()); + pic.close(); + + qInfo() << c.token() << c.answer(); + qInfo() << "Validate" << ZeroStorageCaptcha::validate (c.answer(), c.token()); // first - success + qInfo() << "Validate" << ZeroStorageCaptcha::validate (c.answer(), c.token()); // second - failed return a.exec(); } diff --git a/examples/example2.cpp b/examples/example2.cpp index 57b7711..539ee1a 100644 --- a/examples/example2.cpp +++ b/examples/example2.cpp @@ -1,4 +1,4 @@ -// 2022 (c) GPLv3, acetone at i2pmail.org +// GPLv3 (c) acetone, 2022 // Zero Storage Captcha example #include "zerostoragecaptcha.h" @@ -15,12 +15,15 @@ int main(int argc, char *argv[]) // "Environment=QT_QPA_PLATFORM=offscreen" in systemd service ([Service] section) QApplication a(argc, argv); - ZeroStorageCaptcha captcha; - captcha.generateText(); - qInfo() << captcha.captchaToken(); - qInfo() << captcha.captchaText(); - qInfo() << captcha.captchaPngByteArray().toBase64(); - qInfo() << "Validation:" << ZeroStorageCaptcha::validate(captcha.captchaText(), captcha.captchaToken()); + ZeroStorageCaptcha c("myText"); + QFile pic("c.png"); + if (not pic.open(QIODevice::WriteOnly)) return 1; + pic.write(c.picturePng()); + pic.close(); + + qInfo() << c.token() << c.answer(); + qInfo() << "Validate" << ZeroStorageCaptcha::validate (c.answer(), c.token()); + qInfo() << "Validate" << ZeroStorageCaptcha::validate (c.answer(), c.token()); return a.exec(); } diff --git a/examples/example3.cpp b/examples/example3.cpp deleted file mode 100644 index 94e371f..0000000 --- a/examples/example3.cpp +++ /dev/null @@ -1,31 +0,0 @@ -// 2022 (c) GPLv3, acetone at i2pmail.org -// Zero Storage Captcha example - -#include "zerostoragecaptcha.h" - -#include -#include -#include - -int main(int argc, char *argv[]) -{ - // To start QApplication without X-server (non-GUI system) should use: - // "export QT_QPA_PLATFORM=offscreen" in plain shell - // or - // "Environment=QT_QPA_PLATFORM=offscreen" in systemd service ([Service] section) - QApplication a(argc, argv); - - ZeroStorageCaptcha::setOnlyNumbersMode(true); - auto captcha = ZeroStorageCaptcha::getCaptcha(); - qInfo() << captcha.token() << captcha.answer(); - QFile f("captcha.png"); - if (not f.open(QIODevice::WriteOnly)) - { - return 1; - } - f.write(captcha.picture()); - f.close(); - qInfo() << "Validation: " << ZeroStorageCaptcha::validate(captcha.answer(), captcha.token()); - - return a.exec(); -} diff --git a/pics/cover.png b/pics/cover.png deleted file mode 100755 index cf16513..0000000 Binary files a/pics/cover.png and /dev/null differ diff --git a/pics/difficult0.png b/pics/difficult0.png new file mode 100644 index 0000000..186a1d8 Binary files /dev/null and b/pics/difficult0.png differ diff --git a/pics/difficult1.png b/pics/difficult1.png new file mode 100644 index 0000000..463823c Binary files /dev/null and b/pics/difficult1.png differ diff --git a/pics/difficult2.png b/pics/difficult2.png new file mode 100644 index 0000000..375a199 Binary files /dev/null and b/pics/difficult2.png differ diff --git a/zerostoragecaptcha.cpp b/zerostoragecaptcha.cpp index 6b34067..ec6bb89 100644 --- a/zerostoragecaptcha.cpp +++ b/zerostoragecaptcha.cpp @@ -1,6 +1,8 @@ -// 2022 (c) GPLv3, acetone at i2pmail.org +// GPLv3 (c) acetone, 2022 // Zero Storage Captcha +// PNG generation based on: + /* * Copyright (c) 2014 Omkar Kanase * QtCaptcha: https://github.com/omkar-developer/QtCaptcha @@ -26,13 +28,16 @@ #include #include #include -#include +#include +#include +#include +#include bool ZeroStorageCaptcha::m_onlyNumbers = false; -ZeroStorageCaptcha::ZeroStorageCaptcha() +void ZeroStorageCaptcha::init() { - if (not ZeroStorageCaptchaCrypto::TimeToken::inited()) ZeroStorageCaptchaCrypto::TimeToken::init(); + ZeroStorageCaptchaService::TimeToken::init(); m_hmod1 = 0.0; m_hmod2 = 0.0; @@ -59,32 +64,47 @@ ZeroStorageCaptcha::ZeroStorageCaptcha() } m_padding = 5; +} - setDifficulty(3); - m_captchaText = "NOTSET"; +ZeroStorageCaptcha::ZeroStorageCaptcha() +{ + init(); + setDifficulty(1); +} - qsrand(static_cast(QTime::currentTime().msec())); // randomize +ZeroStorageCaptcha::ZeroStorageCaptcha(const QString &answer, int difficulty) +{ + init(); + setAnswer(answer); + setDifficulty(difficulty); + render(); } bool ZeroStorageCaptcha::validate(const QString &answer, const QString &token) { - return ZeroStorageCaptchaCrypto::KeyHolder::validateCaptchaAnswer(answer, token); + return ZeroStorageCaptchaService::TokenManager::validateAnswer(answer, token); } -ZeroStorageCaptchaContainer ZeroStorageCaptcha::getCaptcha(int length, int difficulty) +void ZeroStorageCaptcha::setCaseSensitive(bool enabled) { - ZeroStorageCaptcha c; - c.setDifficulty(difficulty); - c.generateText(length); - return ZeroStorageCaptchaContainer (c.captchaPngByteArray(), c.captchaToken(), c.captchaText()); + ZeroStorageCaptchaService::TokenManager::setCaseSensitive(enabled); } -QString ZeroStorageCaptcha::captchaToken() const +bool ZeroStorageCaptcha::caseSensitive() { - return ZeroStorageCaptchaCrypto::KeyHolder::captchaSecretLine(m_captchaText); + return ZeroStorageCaptchaService::TokenManager::caseSensitive(); } -QByteArray ZeroStorageCaptcha::captchaPngByteArray() const +QString ZeroStorageCaptcha::token() const +{ + if (m_token.isEmpty()) + { + m_token = ZeroStorageCaptchaService::TokenManager::get(m_captchaText); + } + return m_token; +} + +QByteArray ZeroStorageCaptcha::picturePng() const { QByteArray data; QBuffer buff(&data); @@ -92,14 +112,14 @@ QByteArray ZeroStorageCaptcha::captchaPngByteArray() const return data; } -void ZeroStorageCaptcha::updateCaptcha() +void ZeroStorageCaptcha::render() { QPainterPath path; QFontMetrics fm(m_font); - path.addText(m_vmod2 + m_padding, m_hmod2 - m_padding + fm.height(), font(), captchaText()); + path.addText(m_vmod2 + m_padding, m_hmod2 - m_padding + fm.height(), font(), answer()); - qreal sinrandomness = (static_cast(qrand()) / RAND_MAX) * 5.0; + qreal sinrandomness = QRandomGenerator::system()->generateDouble() * 5.0; for (int i = 0; i < path.elementCount(); ++i) { @@ -126,10 +146,10 @@ void ZeroStorageCaptcha::updateCaptcha() painter.setPen(QPen(Qt::black, m_lineWidth)); for (int i = 0; i < m_lineCount; i++) { - int x1 = static_cast((static_cast(qrand()) / RAND_MAX) * m_captchaImage.width()); - int y1 = static_cast((static_cast(qrand()) / RAND_MAX) * m_captchaImage.height()); - int x2 = static_cast((static_cast(qrand()) / RAND_MAX) * m_captchaImage.width()); - int y2 = static_cast((static_cast(qrand()) / RAND_MAX) * m_captchaImage.height()); + int x1 = static_cast(QRandomGenerator::system()->generateDouble() * m_captchaImage.width()); + int y1 = static_cast(QRandomGenerator::system()->generateDouble() * m_captchaImage.height()); + int x2 = static_cast(QRandomGenerator::system()->generateDouble() * m_captchaImage.width()); + int y2 = static_cast(QRandomGenerator::system()->generateDouble() * m_captchaImage.height()); painter.drawLine(x1, y1, x2, y2); } painter.setPen(Qt::NoPen); @@ -139,10 +159,10 @@ void ZeroStorageCaptcha::updateCaptcha() { for (int i = 0; i < m_ellipseCount; i++) { - int x1 = static_cast(m_ellipseMaxRadius / 2.0 + (static_cast(qrand()) / RAND_MAX) * (m_captchaImage.width() - m_ellipseMaxRadius)); - int y1 = static_cast(m_ellipseMaxRadius / 2.0 + (static_cast(qrand()) / RAND_MAX) * (m_captchaImage.height() - m_ellipseMaxRadius)); - int rad1 = static_cast(m_ellipseMinRadius + (static_cast(qrand()) / RAND_MAX) * (m_ellipseMaxRadius - m_ellipseMinRadius)); - int rad2 = static_cast(m_ellipseMinRadius + (static_cast(qrand()) / RAND_MAX) * (m_ellipseMaxRadius - m_ellipseMinRadius)); + int x1 = static_cast(m_ellipseMaxRadius / 2.0 + QRandomGenerator::system()->generateDouble() * (m_captchaImage.width() - m_ellipseMaxRadius)); + int y1 = static_cast(m_ellipseMaxRadius / 2.0 + QRandomGenerator::system()->generateDouble() * (m_captchaImage.height() - m_ellipseMaxRadius)); + int rad1 = static_cast(m_ellipseMinRadius + QRandomGenerator::system()->generateDouble() * (m_ellipseMaxRadius - m_ellipseMinRadius)); + int rad2 = static_cast(m_ellipseMinRadius + QRandomGenerator::system()->generateDouble() * (m_ellipseMaxRadius - m_ellipseMinRadius)); if (backColor() == Qt::GlobalColor::black) { painter.setBrush(fontColor()); @@ -159,8 +179,8 @@ void ZeroStorageCaptcha::updateCaptcha() { for (int i = 0; i < m_noiseCount; i++) { - int x1 = static_cast(static_cast(qrand()) / RAND_MAX * m_captchaImage.width()); - int y1 = static_cast(static_cast(qrand()) / RAND_MAX * m_captchaImage.height()); + int x1 = static_cast(QRandomGenerator::system()->generateDouble() * m_captchaImage.width()); + int y1 = static_cast(QRandomGenerator::system()->generateDouble() * m_captchaImage.height()); QColor col = backColor() == Qt::GlobalColor::black ? Qt::GlobalColor::white : Qt::GlobalColor::black; @@ -183,84 +203,79 @@ void ZeroStorageCaptcha::setSinDeform(qreal hAmplitude, qreal hFrequency, qreal void ZeroStorageCaptcha::setDifficulty(int val) { - if (val < 0 or val > 5) + short variant = QRandomGenerator::system()->bounded(1, 3); + + if (val < 0 or val > 2) { - qInfo().noquote() << QString(__PRETTY_FUNCTION__) << "Min difficulty is 0, maximal is 5"; + qInfo().noquote() << QString(__PRETTY_FUNCTION__) << "Min difficulty is 0, maximal is 2"; } if (val < 1) { m_drawLines = false; - m_drawEllipses = false; m_drawNoise = false; - setSinDeform(10, 10, 5, 20); + m_drawEllipses = true; + m_ellipseCount = 1; + m_ellipseMinRadius = 10; + m_ellipseMaxRadius = 40; + switch (variant) { + case 0: + setSinDeform(2, 5, 5, 15); + break; + case 1: + setSinDeform(5, 7, 7, 15); + break; + default: + setSinDeform(2, 1, 1, 10); + break; + } } else if (val == 1) - { - m_drawLines = true; - m_lineWidth = 3; - m_lineCount = 5; - m_drawEllipses = false; - m_drawNoise = false; - setSinDeform(10, 15, 5, 20); - } - else if (val == 2) { m_drawLines = true; m_lineWidth = 2; - m_lineCount = 5; - m_drawEllipses = true; - m_ellipseCount = 1; - m_ellipseMinRadius = 20; - m_ellipseMaxRadius = 40; - m_drawNoise = false; - setSinDeform(10, 15, 5, 15); - } - else if (val == 3) - { - m_drawLines = true; - m_lineWidth = 2; - m_lineCount = 3; - m_drawEllipses = true; - m_ellipseCount = 1; - m_ellipseMinRadius = 20; - m_ellipseMaxRadius = 50; - m_drawNoise = true; - m_noiseCount = 100; - m_noisePointSize = 3; - setSinDeform(8, 13, 5, 15); - } - else if (val == 4) - { - m_drawLines = true; - m_lineWidth = 3; - m_lineCount = 5; + m_lineCount = 10; m_drawEllipses = true; m_ellipseCount = 2; m_ellipseMinRadius = 20; - m_ellipseMaxRadius = 40; + m_ellipseMaxRadius = 30; m_drawNoise = true; - m_noiseCount = 100; - m_noisePointSize = 3; - setSinDeform(8, 13, 5, 15); + m_noiseCount = 30; + m_noisePointSize = 5; + switch (variant) { + case 0: + setSinDeform(2, 5, 5, 25); + break; + case 1: + setSinDeform(6, 8, 8, 25); + break; + default: + setSinDeform(3, 6, 6, 15); + break; + } } else { m_drawLines = true; m_lineWidth = 4; - m_lineCount = 7; + m_lineCount = 9; m_drawEllipses = true; m_ellipseCount = 1; - m_ellipseMinRadius = 20; - m_ellipseMaxRadius = 40; + m_ellipseMinRadius = 50; + m_ellipseMaxRadius = 70; m_drawNoise = true; - m_noiseCount = 200; - m_noisePointSize = 3; - setSinDeform(8, 10, 5, 10); + m_noiseCount = 150; + m_noisePointSize = 4; + setSinDeform(2, 5, 5, 15); } } -void ZeroStorageCaptcha::generateText(int length) +void ZeroStorageCaptcha::setAnswer(const QString &answer) +{ + m_captchaText = answer; +} + +void ZeroStorageCaptcha::generateAnswer(int length) { if (length <= 0) { @@ -268,7 +283,165 @@ void ZeroStorageCaptcha::generateText(int length) length = 5; } - m_captchaText = ZeroStorageCaptchaCrypto::random(length, m_onlyNumbers); - - updateCaptcha(); + m_captchaText = ZeroStorageCaptchaService::random(length, m_onlyNumbers); } + +////////////////////////// + +constexpr const int TIME_TOKEN_SIZE = 5; +constexpr const int TIMER_TO_CHANGE_TOKEN_MSECS = 90000; // 1,5 min +constexpr const int KEY_STRING_SIZE = 32; + +namespace ZeroStorageCaptchaService { + +QTimer* TimeToken::m_updater = nullptr; +QString TimeToken::m_current; +QString TimeToken::m_prev; + +QMutex TokenManager::m_usedTokensMtx; +QMap> TokenManager::m_usedTokens; +bool TokenManager::m_caseSensitive = false; +QString TokenManager::m_key = nullptr; + +void TimeToken::init() +{ + if (m_updater) return; + + m_updater = new QTimer; + m_current = ZeroStorageCaptchaService::random(TIME_TOKEN_SIZE) + QString::number(QDateTime::currentSecsSinceEpoch()); + m_updater->setInterval(TIMER_TO_CHANGE_TOKEN_MSECS); + QObject::connect ( + m_updater, &QTimer::timeout, + [&]() { + m_prev = m_current; + m_current = ZeroStorageCaptchaService::random(TIME_TOKEN_SIZE); + TokenManager::removeAllTokensExceptPassed( currentToken(), prevToken() ); + } + ); + m_updater->start(); +} + +std::atomic IdCounter::m_counter = 0; + +size_t IdCounter::get() +{ + size_t value = ++m_counter; + if (value == 0) + { + value++; + } + return value; +} + +QString TokenManager::get(const QString &captchaAnswer, size_t id, bool prevTimeToken) +{ + if (m_key.isEmpty()) + { + m_key = random(KEY_STRING_SIZE); // init at first call + } + + if (id == 0) + { + id = IdCounter::get(); + } + + // ANSWER + TIME_TOKEN + ID + SESSION_KEY + // TIME_TOKEN - temporary marker for limiting captcha life circle + // ID - size_t validation key for concrete captcha + // SESSION_KEY - random run-time session key for unique hash value + const QString base = (m_caseSensitive ? captchaAnswer : captchaAnswer.toUpper()) + + (prevTimeToken ? TimeToken::prevToken() : TimeToken::currentToken()) + + QString::number(id) + m_key; + + const QByteArray hash = QCryptographicHash::hash(base.toUtf8(), QCryptographicHash::Md5); + QString b64Hash = hash.toBase64(QByteArray::Base64Option::Base64UrlEncoding); + static QRegularExpression rgx_OnlyLetters("[^a-zA-Z]"); + b64Hash.remove(rgx_OnlyLetters); + QString token = b64Hash + "_" + QString::number(id); + return token; +} + +bool TokenManager::validateAnswer(const QString &answer, const QString &token) +{ + QString idString {token}; + static QRegularExpression rgx_id("^.*_"); + idString.remove (rgx_id); + bool idConvertingStatus = false; + size_t id = idString.toULongLong(&idConvertingStatus); + if (not idConvertingStatus or id == 0) + { + return false; + } + + QString timeKey; + if (TokenManager::get(answer, id) == token) + { + timeKey = TimeToken::currentToken(); + } + else if (TokenManager::get(answer, id, true) == token) + { + timeKey = TimeToken::prevToken(); + } + + if (timeKey.isEmpty()) + { + return false; + } + + QMutexLocker lock (&m_usedTokensMtx); + if (m_usedTokens.contains(timeKey)) + { + if (m_usedTokens[timeKey].contains(id)) // already used + { + return false; + } + } + + m_usedTokens[timeKey].insert( id ); + return true; +} + +void TokenManager::removeAllTokensExceptPassed(const QString& current, const QString& prev) +{ + QMutexLocker lock (&m_usedTokensMtx); + + std::list>::iterator> toRemove; + + for (auto iter = m_usedTokens.begin(); iter != m_usedTokens.end(); iter++) + { + if (iter.key() != current and iter.key() != prev) + { + toRemove.push_back(iter); + } + } + + for (const auto& iter: toRemove) + { + m_usedTokens.erase(iter); + } +} + +QByteArray random(int length, bool onlyNumbers) +{ + constexpr char randomtable[60] = + {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', + 'k', 'l', 'm', 'n', 'k', 'p', 'q', 'r', 's', 't', + 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C', 'D', + 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', + 'h', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X'}; + + QByteArray random_value; + + while(random_value.size() < length) + { + random_value += randomtable[ QRandomGenerator::system()->bounded ( + onlyNumbers ? 0 : 1, + onlyNumbers ? 9 : 59 + ) ]; + } + + return random_value; +} + +} // namespace ZeroStorageCaptchaService diff --git a/zerostoragecaptcha.h b/zerostoragecaptcha.h index e13bc57..c1cc8d0 100644 --- a/zerostoragecaptcha.h +++ b/zerostoragecaptcha.h @@ -1,6 +1,8 @@ -// 2022 (c) GPLv3, acetone at i2pmail.org +// GPLv3 (c) acetone, 2022 // Zero Storage Captcha +// PNG generation based on: + /* * Copyright (c) 2014 Omkar Kanase * QtCaptcha: https://github.com/omkar-developer/QtCaptcha @@ -23,43 +25,30 @@ #ifndef ZEROSTORAGECAPTCHA_H #define ZEROSTORAGECAPTCHA_H -#include "zerostoragecaptchacrypto.h" - #include #include - -class ZeroStorageCaptchaContainer -{ -public: - ZeroStorageCaptchaContainer(const QByteArray& pic, const QString& token, const QString& answer) : - m_picture(pic), - m_token(token), - m_answer(answer) - {} - - const QByteArray& picture() const { return m_picture; } - const QString& token() const { return m_token; } - const QString& answer() const { return m_answer; } - -private: - QByteArray m_picture; - QString m_token; - QString m_answer; -}; +#include +#include +#include +#include +#include class ZeroStorageCaptcha { public: ZeroStorageCaptcha(); + ZeroStorageCaptcha(const QString& answer, int difficulty = 1); static bool validate(const QString& answer, const QString& token); - static ZeroStorageCaptchaContainer getCaptcha(int length = 5, int difficulty = 3); - static void setOnlyNumbersMode(bool enabled = false) { m_onlyNumbers = enabled; } + static void setNumbersOnlyMode(bool enabled = false) { m_onlyNumbers = enabled; } + static bool numbersOnlyMode() { return m_onlyNumbers; } + static void setCaseSensitive(bool enabled = false); + static bool caseSensitive(); - QString captchaText() const { return m_captchaText; } - QString captchaToken() const; - QByteArray captchaPngByteArray() const; + QString answer() const { return m_captchaText; } + QString token() const; + QByteArray picturePng() const; - QImage captchaImage() const { return m_captchaImage; } + QImage qimage() const { return m_captchaImage; } QFont font() const { return m_font; } QColor fontColor() const { return m_fontColor; } QColor backColor() const { return m_backColor; } @@ -90,10 +79,12 @@ public: void setNoisePointSize(int arg) { m_noisePointSize = arg; } void setSinDeform(qreal hAmplitude, qreal hFrequency, qreal vAmplitude, qreal vFrequency); void setDifficulty(int val); - void generateText(int length = 5); - void updateCaptcha(); + void setAnswer(const QString& answer); + void generateAnswer(int length = 5); + void render(); private: + void init(); static bool m_onlyNumbers; qreal m_hmod1; @@ -104,7 +95,7 @@ private: QFont m_font; QImage m_captchaImage; - QString m_captchaText; + QString m_captchaText = "empty"; QColor m_fontColor; QColor m_backColor; qreal m_padding; @@ -118,6 +109,62 @@ private: int m_ellipseMinRadius; int m_ellipseMaxRadius; int m_noisePointSize; + + mutable QString m_token; }; +/////////////////////////////////// + +namespace ZeroStorageCaptchaService { + +QByteArray random(int length, bool onlyNumbers = false); + +class TimeToken +{ +public: + TimeToken() = delete; + + static void init(); + static const QString currentToken() { return m_current; } + static const QString prevToken() { return m_prev; } + +private: + static QTimer* m_updater; + static QString m_current; + static QString m_prev; +}; + +class IdCounter +{ +public: + IdCounter() = delete; + + static size_t get(); + +private: + static std::atomic m_counter; +}; + +class TokenManager +{ +public: + TokenManager() = delete; + + static QString get(const QString& captchaAnswer, size_t id = 0, bool prevTimeToken = false); + static bool validateAnswer(const QString& answer, const QString& token); + static void setCaseSensitive(bool enabled = false) { m_caseSensitive = enabled; } + static bool caseSensitive() { return m_caseSensitive; } + + friend TimeToken; + +private: + static void removeAllTokensExceptPassed(const QString& current, const QString& prev); + static QMutex m_usedTokensMtx; + static QMap> m_usedTokens; + static bool m_caseSensitive; + static QString m_key; +}; + +} // namespace + #endif // ZEROSTORAGECAPTCHA_H diff --git a/zerostoragecaptchacrypto.cpp b/zerostoragecaptchacrypto.cpp deleted file mode 100644 index 8e73ec0..0000000 --- a/zerostoragecaptchacrypto.cpp +++ /dev/null @@ -1,192 +0,0 @@ -// 2022 (c) GPLv3, acetone at i2pmail.org -// Zero Storage Captcha - -#include "zerostoragecaptchacrypto.h" - -#include -#include -#include -#include -#include - -constexpr int TIME_TOKEN_SIZE = 10; -constexpr int DEFAULT_SIZE_OF_USED_TOKENS_CACHE = 100000; -constexpr int TIMER_TO_CHANGE_TOKEN_MSECS = 90000; // 1,5 min - -namespace ZeroStorageCaptchaCrypto { - -QTimer* TimeToken::m_updater = nullptr; -QString TimeToken::m_current; -QString TimeToken::m_prev; -int KeyHolder::m_maximalSizeOfUsedMap = DEFAULT_SIZE_OF_USED_TOKENS_CACHE; -QMutex KeyHolder::m_usedTokensMtx; -QMultiMap KeyHolder::m_usedTokens; -bool KeyHolder::m_caseSensitive = false; -uint8_t KeyHolder::m_key[KEYSIZE] {0}; - -void TimeToken::init() -{ - if (m_updater) return; - - m_updater = new QTimer; - m_current = ZeroStorageCaptchaCrypto::random(TIME_TOKEN_SIZE); - m_updater->setInterval(TIMER_TO_CHANGE_TOKEN_MSECS); - QObject::connect(m_updater, &QTimer::timeout, [&]() { - KeyHolder::removeOldToken(m_prev); - m_prev = m_current; - m_current = ZeroStorageCaptchaCrypto::random(TIME_TOKEN_SIZE); - }); - m_updater->start(); -} - -QString KeyHolder::captchaSecretLine(const QString &captchaAnswer, bool prevTimeToken) -{ - if (m_usedTokens.size() > m_maximalSizeOfUsedMap) - { - warningLog(); - return QString(); - } - - if (m_key[0] == 0) - { - auto noize = ZeroStorageCaptchaCrypto::random(KEYSIZE); - for (int i = 0; i < KEYSIZE; ++i) - { - m_key[i] = static_cast(noize[i]); - } - } - - QString hashedAnswer = ZeroStorageCaptchaCrypto::hash((m_caseSensitive ? captchaAnswer : captchaAnswer.toUpper()) + - (prevTimeToken ? TimeToken::prevToken() : TimeToken::currentToken()) ); - - uint8_t signature[SIGSIZE]; - sign(reinterpret_cast(hashedAnswer.toStdString().c_str()), static_cast(hashedAnswer.size()), signature, m_key); - - QByteArray rawResultArray; - for(int i = 0; i < SIGSIZE; ++i) - { - rawResultArray += static_cast(signature[i]); - } - - return compact(rawResultArray.toBase64(QByteArray::Base64Option::Base64UrlEncoding)); -} - -bool KeyHolder::validateCaptchaAnswer(const QString &answer, const QString &secretLine) -{ - QString timeKey; - if (captchaSecretLine(answer) == secretLine) - { - timeKey = TimeToken::currentToken(); - } - else if (captchaSecretLine(answer, true) == secretLine) - { - timeKey = TimeToken::prevToken(); - } - - if (not timeKey.isEmpty()) - { - QMutexLocker lock (&m_usedTokensMtx); - if (m_usedTokens.size() > m_maximalSizeOfUsedMap) - { - warningLog(); - return false; - } - if (m_usedTokens.find( timeKey ) == m_usedTokens.end()) - { - m_usedTokens.insert(timeKey, secretLine); - return true; - } - } - - return false; -} - -void KeyHolder::removeOldToken(const QString &oldPrevToken) -{ - QMutexLocker lock (&m_usedTokensMtx); - m_usedTokens.remove(oldPrevToken); -} - -QString KeyHolder::compact(const QString &str) -{ - QString result; - int counter = 0; - for (const auto& c: str) - { - if (++counter % 3 == 0) - { - result += c; - } - } - result.remove('='); - result.remove('_'); - result.remove('-'); - return result; -} - -void KeyHolder::sign(const uint8_t *buf, size_t len, uint8_t *signature, const uint8_t *privateKey) -{ - auto MDCtx = EVP_MD_CTX_create (); - auto PKey = EVP_PKEY_new_raw_private_key (EVP_PKEY_ED25519, NULL, privateKey, KEYSIZE); - EVP_DigestSignInit (MDCtx, NULL, NULL, NULL, PKey); - - size_t l = SIGSIZE; - EVP_DigestSign (MDCtx, signature, &l, buf, len); - - EVP_PKEY_free (PKey); - EVP_MD_CTX_destroy (MDCtx); -} - -void KeyHolder::warningLog() -{ - qInfo().noquote() << - "\n" - " Token cache is full (" + QString::number(m_maximalSizeOfUsedMap) + "). Service temporary unavailable.\n" - " You can increase maximal cache size via ZeroStorageCaptchaCrypto::KeyHolder::setMaxSizeOfUsedTokensCache(size_t)\n" - ""; -} - -QString hash(const QString &str) -{ - QVector in; - for(auto c: str) - { - in.push_back(static_cast(c.toLatin1())); - } - - QVector out(SHA256_DIGEST_LENGTH); - SHA256(in.data(), static_cast(in.size()), out.data()); - - QByteArray rawResult; - for (auto b: out) - { - rawResult.push_back(static_cast(b)); - } - - return rawResult.toBase64(QByteArray::Base64Option::Base64UrlEncoding); -} - -QByteArray random(int length, bool onlyNumbers) -{ - constexpr char randomtable[60] = - {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', - 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', - 'k', 'l', 'm', 'n', 'k', 'p', 'q', 'r', 's', 't', - 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C', 'D', - 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', - 'h', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X'}; - - QByteArray random_value; - - std::random_device rd; - std::uniform_int_distribution dist(onlyNumbers ? 0 : 1, onlyNumbers ? 9 : 59); - - while(random_value.size() < length) - { - random_value += randomtable[dist(rd)]; - } - - return random_value; -} - -} // namespace diff --git a/zerostoragecaptchacrypto.h b/zerostoragecaptchacrypto.h deleted file mode 100644 index ea29777..0000000 --- a/zerostoragecaptchacrypto.h +++ /dev/null @@ -1,68 +0,0 @@ -// 2022 (c) GPLv3, acetone at i2pmail.org -// Zero Storage Captcha - -#ifndef ZEROSTORAGECAPTCHACRYPTO_H -#define ZEROSTORAGECAPTCHACRYPTO_H - -#include -#include -#include -#include -#include - -constexpr int KEYSIZE = 32; -constexpr int SIGSIZE = 64; - -namespace ZeroStorageCaptchaCrypto { - -QString hash(const QString& str); -QByteArray random(int length, bool onlyNumbers = false); - -///////// - -class TimeToken -{ -public: - TimeToken() = delete; - - static void init(); - static bool inited() { return m_updater; } - static const QString currentToken() { return m_current; } - static const QString prevToken() { return m_prev; } - -private: - static QTimer* m_updater; - static QString m_current; - static QString m_prev; -}; - -///////// - -class KeyHolder -{ -public: - KeyHolder() = delete; - - static QString captchaSecretLine(const QString& captchaAnswer, bool prevTimeToken = false); - static bool validateCaptchaAnswer(const QString& answer, const QString& secretLine); - static void setCaseSensitive(bool enabled = false) { m_caseSensitive = enabled; } - static void setMaxSizeOfUsedTokensCache(size_t size) { m_maximalSizeOfUsedMap = size; } - - friend TimeToken; - -private: - static QString compact(const QString& str); - static void sign(const uint8_t * buf, size_t len, uint8_t * signature, const uint8_t * privateKey); - - static void warningLog(); - static void removeOldToken(const QString& oldPrevToken); - static QMutex m_usedTokensMtx; - static QMultiMap m_usedTokens; - static int m_maximalSizeOfUsedMap; - static bool m_caseSensitive; - static uint8_t m_key[KEYSIZE]; -}; - -} // namespace - -#endif // ZEROSTORAGECAPTCHACRYPTO_H