Cockatrice/tests/password_hash_test.cpp
BruebachL b37c8b828f
[Utility] Budget scrypt's XY array in the derivation memory limit (#7712)
deriveKey passes maxmem = 128nr + 128rp + 4096 to EVP_PBE_scrypt, but scrypt
also allocates XY at 256r bytes. costParamsAreSane accepts r up to 32, so any
server asking for r >= 17 made derivation fail and the client report a login
abort it could never recover from. Cover the XY array and add a regression test
at the accepted-range boundary.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:29:15 +01:00

259 lines
11 KiB
C++

#include "gtest/gtest.h"
#include <QChar>
#include <QString>
#include <QStringList>
#include <cstring>
#include <libcockatrice/utility/cryptoutil.h>
#include <libcockatrice/utility/passwordhasher.h>
#include <string>
namespace
{
TEST(PasswordHashTest, RegressionTest)
{
QString salt = "saltsaltsaltsalt";
QString password = "password";
QString expected = "vmKoWv975yf+WT2QCXhW48JNzZ2ghGxdgNvuKLBU0h7s6AQHSG72J6QO4ZswuSeqvBbAXbmgJSRBaSJrgc55WA==";
QString hash = PasswordHasher::computeHash(password, salt);
ASSERT_EQ(hash, salt + expected) << "The computed hash value remains the same";
}
TEST(PasswordHashTest, SaltUsesAlphanumericCharset)
{
static const char alphanum[] = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
const QString salt = PasswordHasher::generateRandomSalt();
ASSERT_EQ(salt.size(), 16);
for (const QChar &c : salt) {
ASSERT_NE(strchr(alphanum, c.toLatin1()), nullptr);
}
}
TEST(PasswordHashTest, SaltsAreUnique)
{
const QString salt1 = PasswordHasher::generateRandomSalt();
const QString salt2 = PasswordHasher::generateRandomSalt();
ASSERT_NE(salt1, salt2);
}
TEST(PasswordHashTest, TokenHasExpectedLength)
{
const QString token = PasswordHasher::generateActivationToken();
ASSERT_EQ(token.size(), 16);
}
TEST(PasswordHashTest, DeriveKeyMatchesKnownVector)
{
// RFC 7914 scrypt test vector, P="password", S="NaCl", N=1024, r=8, p=16
const QByteArray expected = QByteArray::fromHex("fdbabe1c9d3472007856e7190d01e9fe7c6ad7cbc8237830e77376634b"
"3731622eaf30d92e22a3886ff109279d9830dac727afb94a83ee6d8360cb"
"dfa2cc0640");
const QByteArray derived = PasswordHasher::deriveKey("password", QByteArray("NaCl"), 1024, 8, 16);
ASSERT_EQ(derived.toHex(), expected.toHex());
}
TEST(PasswordHashTest, DeriveKeyCoversTheWholeAcceptedRangeOfR)
{
// costParamsAreSane accepts r up to 32. The memory budget handed to OpenSSL has to cover
// scrypt's XY array (256 * r bytes) on top of V and B, or derivation fails for r >= 17.
for (const int r : {17, 32}) {
const QByteArray derived = PasswordHasher::deriveKey("password", QByteArray("NaCl"), 1024, r, 1);
ASSERT_FALSE(derived.isEmpty()) << "deriveKey failed for accepted r=" << r;
ASSERT_EQ(derived.size(), SCRYPT_VERIFIER_LENGTH);
}
}
TEST(PasswordHashTest, PasswordVerifierRoundTrip)
{
const QString stored = PasswordHasher::generatePasswordVerifier("hunter2");
ASSERT_FALSE(stored.isEmpty());
ASSERT_TRUE(stored.startsWith("$scrypt$"));
const PasswordVerifier parsed = PasswordHasher::parsePasswordVerifier(stored);
ASSERT_TRUE(parsed.isValid);
ASSERT_EQ(parsed.format, PasswordFormat::Scrypt);
ASSERT_EQ(parsed.n, SCRYPT_N);
ASSERT_EQ(parsed.r, SCRYPT_R);
ASSERT_EQ(parsed.p, SCRYPT_P);
ASSERT_EQ(parsed.salt.size(), SCRYPT_SALT_LENGTH);
ASSERT_EQ(parsed.verifier.size(), SCRYPT_VERIFIER_LENGTH);
}
TEST(PasswordHashTest, PasswordVerifierInvalidInput)
{
ASSERT_FALSE(PasswordHasher::parsePasswordVerifier("garbage").isValid);
ASSERT_FALSE(PasswordHasher::parsePasswordVerifier("$scrypt$not-an-int$8$1$AAAA$BBBB").isValid);
ASSERT_FALSE(PasswordHasher::parsePasswordVerifier("$scrypt$1024$8$1$AAAA$too-short").isValid);
ASSERT_FALSE(PasswordHasher::parsePasswordVerifier("$pbkdf2-sha512$1000$AAAA$BBBB").isValid);
}
TEST(PasswordHashTest, LegacyFormatDetection)
{
ASSERT_TRUE(PasswordHasher::isLegacyFormat("salt+hash"));
ASSERT_FALSE(PasswordHasher::isLegacyFormat(PasswordHasher::generatePasswordVerifier("password")));
}
TEST(PasswordHashTest, DeriveKeyDependsOnCostParameters)
{
const QByteArray keyA = PasswordHasher::deriveKey("password", QByteArray("NaCl"), 1024, 8, 16);
const QByteArray keyB = PasswordHasher::deriveKey("password", QByteArray("NaCl"), 2048, 8, 16);
const QByteArray keyC = PasswordHasher::deriveKey("password", QByteArray("NaCl"), 1024, 8, 1);
ASSERT_NE(keyA, keyB);
ASSERT_NE(keyA, keyC);
}
TEST(PasswordHashTest, ComputeResponseIsDeterministic)
{
const QByteArray nonce = QByteArray("a nonce value");
const QByteArray key = QByteArray("the verifier bytes");
const QByteArray r1 = PasswordHasher::computeResponse(key, nonce);
const QByteArray r2 = PasswordHasher::computeResponse(key, nonce);
const QByteArray r3 = PasswordHasher::computeResponse(QByteArray("a different key"), nonce);
ASSERT_EQ(r1, r2);
ASSERT_NE(r1, r3);
}
TEST(PasswordHashTest, ConstantTimeEquals)
{
ASSERT_TRUE(PasswordHasher::constantTimeEquals(QByteArray("same"), QByteArray("same")));
ASSERT_FALSE(PasswordHasher::constantTimeEquals(QByteArray("same"), QByteArray("diff")));
ASSERT_FALSE(PasswordHasher::constantTimeEquals(QByteArray("short"), QByteArray("longer")));
}
TEST(PasswordHashTest, CostParamsAreSane)
{
// Accept the recommended interactive parameters and the RFC 7914 test vector's.
ASSERT_TRUE(PasswordHasher::costParamsAreSane(SCRYPT_N, SCRYPT_R, SCRYPT_P));
ASSERT_TRUE(PasswordHasher::costParamsAreSane(1024, 8, 16));
// n must be in [1024, 2**20] and a power of two.
ASSERT_FALSE(PasswordHasher::costParamsAreSane(512, 8, 1));
ASSERT_FALSE(PasswordHasher::costParamsAreSane(1 << 21, 8, 1));
ASSERT_FALSE(PasswordHasher::costParamsAreSane(1025, 8, 1));
ASSERT_FALSE(PasswordHasher::costParamsAreSane(0, 8, 1));
ASSERT_FALSE(PasswordHasher::costParamsAreSane(-1024, 8, 1));
// r in [1, 32], p in [1, 16].
ASSERT_FALSE(PasswordHasher::costParamsAreSane(1024, 0, 1));
ASSERT_FALSE(PasswordHasher::costParamsAreSane(1024, 33, 1));
ASSERT_FALSE(PasswordHasher::costParamsAreSane(1024, 8, 0));
ASSERT_FALSE(PasswordHasher::costParamsAreSane(1024, 8, 17));
}
TEST(PasswordHashTest, ParsePasswordVerifierRejectsHostileCostParams)
{
// 16 bytes of salt and 64 bytes of verifier, base64 encoded.
const QString saltB64 = QLatin1String("c2FsdHNhbHRzYWx0c2FsdA==");
const QString verifierB64 = QString(QByteArray(SCRYPT_VERIFIER_LENGTH, '\x42').toBase64());
ASSERT_TRUE(
PasswordHasher::parsePasswordVerifier(QString("$scrypt$1024$8$1$%1$%2").arg(saltB64).arg(verifierB64)).isValid);
// n not a power of two, below 1024, or above 2**20.
ASSERT_FALSE(
PasswordHasher::parsePasswordVerifier(QString("$scrypt$1025$8$1$%1$%2").arg(saltB64).arg(verifierB64)).isValid);
ASSERT_FALSE(
PasswordHasher::parsePasswordVerifier(QString("$scrypt$512$8$1$%1$%2").arg(saltB64).arg(verifierB64)).isValid);
ASSERT_FALSE(
PasswordHasher::parsePasswordVerifier(QString("$scrypt$1073741824$8$1$%1$%2").arg(saltB64).arg(verifierB64))
.isValid);
// r and p out of range.
ASSERT_FALSE(PasswordHasher::parsePasswordVerifier(QString("$scrypt$1024$33$1$%1$%2").arg(saltB64).arg(verifierB64))
.isValid);
ASSERT_FALSE(PasswordHasher::parsePasswordVerifier(QString("$scrypt$1024$8$17$%1$%2").arg(saltB64).arg(verifierB64))
.isValid);
}
TEST(PasswordHashTest, VerifyPasswordLegacyRow)
{
const QString salt = PasswordHasher::generateRandomSalt();
const QString legacyStored = PasswordHasher::computeHash("correct horse", salt);
ASSERT_TRUE(PasswordHasher::verifyPassword("correct horse", legacyStored));
ASSERT_FALSE(PasswordHasher::verifyPassword("battery staple", legacyStored));
}
TEST(PasswordHashTest, VerifyPasswordScryptRow)
{
const QString scryptStored = PasswordHasher::generatePasswordVerifier("correct horse");
// Regression for the changeUserPassword bug that re-hashed the old password with
// a 16-char salt torn out of the "$scrypt$..." string, which could never match.
ASSERT_TRUE(PasswordHasher::verifyPassword("correct horse", scryptStored));
ASSERT_FALSE(PasswordHasher::verifyPassword("battery staple", scryptStored));
ASSERT_FALSE(PasswordHasher::verifyPassword("correct horse", "garbage"));
}
TEST(CryptoUtilTest, SecretRoundTrip)
{
const QByteArray key = CryptoUtil::randomBytes(32);
const QByteArray plaintext = PasswordHasher::computeHash("correct horse", "saltsaltsaltsalt").toUtf8();
const QByteArray aad = "alice";
const QString sealed = CryptoUtil::encryptSecret(plaintext, key, aad);
ASSERT_FALSE(sealed.isEmpty());
ASSERT_TRUE(sealed.startsWith("enc1:"));
ASSERT_EQ(CryptoUtil::decryptSecret(sealed, key, aad), plaintext);
}
TEST(CryptoUtilTest, SealIsNonDeterministic)
{
const QByteArray key = CryptoUtil::randomBytes(32);
const QByteArray plaintext = "same secret";
// Fresh random nonce per seal: identical inputs must not produce identical blobs.
ASSERT_NE(CryptoUtil::encryptSecret(plaintext, key), CryptoUtil::encryptSecret(plaintext, key));
}
TEST(CryptoUtilTest, RejectsWrongKey)
{
const QByteArray key = CryptoUtil::randomBytes(32);
const QByteArray otherKey = CryptoUtil::randomBytes(32);
const QString sealed = CryptoUtil::encryptSecret("secret", key, "alice");
ASSERT_FALSE(sealed.isEmpty());
ASSERT_TRUE(CryptoUtil::decryptSecret(sealed, otherKey, "alice").isEmpty());
}
TEST(CryptoUtilTest, RejectsWrongAad)
{
const QByteArray key = CryptoUtil::randomBytes(32);
const QString sealed = CryptoUtil::encryptSecret("secret", key, "alice");
ASSERT_FALSE(sealed.isEmpty());
ASSERT_TRUE(CryptoUtil::decryptSecret(sealed, key, "bob").isEmpty());
ASSERT_TRUE(CryptoUtil::decryptSecret(sealed, key).isEmpty());
}
TEST(CryptoUtilTest, RejectsTampering)
{
const QByteArray key = CryptoUtil::randomBytes(32);
const QString sealed = CryptoUtil::encryptSecret("secret", key);
ASSERT_FALSE(sealed.isEmpty());
// Flip a character inside the ciphertext segment (keep it valid base64).
QStringList parts = sealed.split(':');
ASSERT_EQ(parts.size(), 3);
QByteArray ciphertext = QByteArray::fromBase64(parts.at(2).toUtf8());
ciphertext[0] = static_cast<char>(ciphertext.at(0) ^ 0x01);
parts[2] = QString(ciphertext.toBase64());
ASSERT_TRUE(CryptoUtil::decryptSecret(parts.join(':'), key).isEmpty());
}
TEST(CryptoUtilTest, RejectsMalformedInput)
{
const QByteArray key = CryptoUtil::randomBytes(32);
ASSERT_TRUE(CryptoUtil::decryptSecret("garbage", key).isEmpty());
ASSERT_TRUE(CryptoUtil::decryptSecret("enc1:notbase64!!:also-not!", key).isEmpty());
ASSERT_TRUE(CryptoUtil::decryptSecret("enc1:AAAAAAAAAAAAAAAA:AAAA", key).isEmpty());
ASSERT_TRUE(CryptoUtil::decryptSecret("", key).isEmpty());
// A short or oversized key must never reach the cipher.
ASSERT_TRUE(
CryptoUtil::decryptSecret("enc1:AAAAAAAAAAAAAAAA:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", CryptoUtil::randomBytes(16))
.isEmpty());
ASSERT_TRUE(CryptoUtil::encryptSecret("secret", CryptoUtil::randomBytes(16)).isEmpty());
}
} // namespace
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}