package fr.xephi.authme.security.crypts; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import fr.xephi.authme.TestHelper; import fr.xephi.authme.security.crypts.description.AsciiRestricted; import org.junit.BeforeClass; import org.junit.Test; import java.util.List; import java.util.Map; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertThat; import static org.junit.Assert.assertTrue; import static org.junit.Assume.assumeThat; /** * Test for implementations of {@link EncryptionMethod}. */ public abstract class AbstractEncryptionMethodTest { /** The username used to query {@link EncryptionMethod#comparePassword}. */ private static final String USERNAME = "Test_Player00"; /** * List of passwords whose hash is provided to the class to test against; this verifies that previously constructed * hashes remain valid. */ private static final String[] GIVEN_PASSWORDS = {"password", "PassWord1", "&^%te$t?Pw@_", "âË_3(íù*"}; /** * List of passwords that are hashed at runtime and then tested against; this verifies that newly generated hashes * are valid. */ private static final List INTERNAL_PASSWORDS = ImmutableList.of("test1234", "Ab_C73", "(!#&$~`_-Aa0", "Ûïé1&?+A"); private static final String[] BOGUS_HASHES = {"", "test", "$t$test$", "$SHA$Test$$$$", "$$$$$", "asdfg:hjkl", "::test", "~#$#~~~#$#~", "d41d8cd98f00b204e9800998ecf427e", "$2y$7a$da641e404b982ed" }; /** * Certain hash algorithms are slow by design, which has a considerable effect on these unit tests. * Setting the property below to "true" will reduce the checks in these unit tests as to offer fast, * partial tests during development. */ private static final boolean SKIP_LONG_TESTS = "true".equals(System.getProperty("project.skipExtendedHashTests")); /** The encryption method to test. */ private EncryptionMethod method; /** Map with the hashes against which the entries in GIVEN_PASSWORDS are tested. */ private Map hashes; /** * Creates a new test for the given encryption method. This is for encryption methods that do not have * a separate salt. * * @param method the encryption method to test * @param hash0 the pre-generated hash for the first entry in {@link #GIVEN_PASSWORDS} * @param hash1 hash for second given password * @param hash2 hash for third given password * @param hash3 hash for fourth given password */ public AbstractEncryptionMethodTest(EncryptionMethod method, String hash0, String hash1, String hash2, String hash3) { verifyCorrectConstructorIsUsed(method, false); this.method = method; hashes = ImmutableMap.of( GIVEN_PASSWORDS[0], new HashedPassword(hash0), GIVEN_PASSWORDS[1], new HashedPassword(hash1), GIVEN_PASSWORDS[2], new HashedPassword(hash2), GIVEN_PASSWORDS[3], new HashedPassword(hash3)); } /** * Creates a new test for the given encryption method. This is for encryption methods that use * a separate salt. * * @param method the encryption method to test * @param result0 the pre-generated hash for the first entry in {@link #GIVEN_PASSWORDS} * @param result1 hash for second given password * @param result2 hash for third given password * @param result3 hash for fourth given password */ public AbstractEncryptionMethodTest(EncryptionMethod method, HashedPassword result0, HashedPassword result1, HashedPassword result2, HashedPassword result3) { verifyCorrectConstructorIsUsed(method, true); this.method = method; hashes = ImmutableMap.of( GIVEN_PASSWORDS[0], result0, GIVEN_PASSWORDS[1], result1, GIVEN_PASSWORDS[2], result2, GIVEN_PASSWORDS[3], result3); } @BeforeClass public static void setupLogger() { TestHelper.setupLogger(); } protected void verifyCorrectConstructorIsUsed(EncryptionMethod method, boolean isConstructorWithSalt) { if (isConstructorWithSalt && !method.hasSeparateSalt()) { throw new UnsupportedOperationException("Salt is not stored separately, so test should be initialized" + " with the password hashes only. Use the other constructor"); } else if (!isConstructorWithSalt && method.hasSeparateSalt()) { throw new UnsupportedOperationException("Test must be initialized with HashedPassword objects if " + "the salt is stored separately. Use the other constructor"); } } @Test public void testGivenPasswords() { // Start with the 2nd to last password if we skip long tests int start = SKIP_LONG_TESTS ? GIVEN_PASSWORDS.length - 2 : 0; // Test entries in GIVEN_PASSWORDS except the last one for (int i = start; i < GIVEN_PASSWORDS.length - 1; ++i) { String password = GIVEN_PASSWORDS[i]; assertTrue("Hash for password '" + password + "' should match", doesGivenHashMatch(password, method)); } // Note #375: Windows console seems to use its own character encoding (Windows-1252?) and it seems impossible to // force it to use UTF-8, so passwords with non-ASCII characters will fail. Since we do not recommend to use // such characters in passwords (something outside of our control, e.g. a database system, might also cause // problems), we will check the last password in GIVEN_PASSWORDS in a non-failing way; if the hash doesn't match // we'll just issue a message to System.err String lastPassword = GIVEN_PASSWORDS[GIVEN_PASSWORDS.length - 1]; if (!doesGivenHashMatch(lastPassword, method)) { System.err.println("Note: Hash for password '" + lastPassword + "' does not match for method " + method); } } @Test public void testPasswordEquality() { List internalPasswords = method.getClass().isAnnotationPresent(AsciiRestricted.class) ? INTERNAL_PASSWORDS.subList(0, INTERNAL_PASSWORDS.size() - 1) : INTERNAL_PASSWORDS; for (String password : internalPasswords) { final String salt = method.generateSalt(); final String hash = method.computeHash(password, salt, USERNAME); HashedPassword hashedPassword = new HashedPassword(hash, salt); // Check that the computeHash(password, salt, name) method has the same output for the returned salt if (testHashEqualityForSameSalt()) { assertThat("Computing a hash with the same salt will generate the same hash", hash, equalTo(method.computeHash(password, salt, USERNAME))); } assertTrue("Generated hash for '" + password + "' should match password (hash = '" + hash + "')", method.comparePassword(password, hashedPassword, USERNAME)); assumeThat(SKIP_LONG_TESTS, equalTo(false)); if (!password.equals(password.toLowerCase())) { assertFalse("Lower-case of '" + password + "' should not match generated hash '" + hash + "'", method.comparePassword(password.toLowerCase(), hashedPassword, USERNAME)); } if (!password.equals(password.toUpperCase())) { assertFalse("Upper-case of '" + password + "' should not match generated hash '" + hash + "'", method.comparePassword(password.toUpperCase(), hashedPassword, USERNAME)); } } } /** Tests various strings to ensure that encryption methods don't rely on the hash's format too much. */ @Test public void testMalformedHashes() { assumeThat(SKIP_LONG_TESTS, equalTo(false)); String salt = method.hasSeparateSalt() ? "testSalt" : null; for (String bogusHash : BOGUS_HASHES) { HashedPassword hashedPwd = new HashedPassword(bogusHash, salt); assertFalse("Passing bogus hash '" + bogusHash + "' does not result in an error", method.comparePassword("Password", hashedPwd, "player")); } } private boolean doesGivenHashMatch(String password, EncryptionMethod method) { return method.comparePassword(password, hashes.get(password), USERNAME); } /** * Generates a test class for a given encryption method. Simply create a test class and run the following code, * replacing {@code XXX} with the actual class: *

* @org.junit.Test public void generate() { AbstractEncryptionMethodTest.generateTest(new XXX()); } *

* The output is the entire test class. * * @param method The method to create a test class for */ protected static void generateTest(EncryptionMethod method) { String className = method.getClass().getSimpleName(); // Create javadoc and "public class extends" and the constructor call "super(new Class()," System.out.println("/**\n * Test for {@link " + className + "}.\n */"); System.out.println("public class " + className + "Test extends AbstractEncryptionMethodTest {"); System.out.println("\n\tpublic " + className + "Test() {"); System.out.println("\t\tsuper(new " + className + "(),"); // Iterate through the GIVEN_PASSWORDS and generate a hash so we can always check it later String delim = ", "; for (String password : GIVEN_PASSWORDS) { if (password.equals(GIVEN_PASSWORDS[GIVEN_PASSWORDS.length - 1])) { delim = "); "; } // Encr. method uses separate salt, so we need to call the constructor that takes HashedPassword instances if (method.hasSeparateSalt()) { HashedPassword hashedPassword = method.computeHash(password, USERNAME); System.out.println(String.format("\t\tnew HashedPassword(\"%s\", \"%s\")%s// %s", hashedPassword.getHash(), hashedPassword.getSalt(), delim, password)); } else { // Encryption method doesn't have separate salt, so simply pass the generated hash to the constructor System.out.println("\t\t\"" + method.computeHash(password, USERNAME).getHash() + "\"" + delim + "// " + password); } } // Close the constructor and class declarations System.out.println("\t}"); System.out.println("\n}"); } /** * Return whether an encryption algorithm should be tested that it generates the same * hash for the same salt. If {@code true}, we call {@link EncryptionMethod#computeHash(String, String)} * and verify that {@link EncryptionMethod#computeHash(String, String, String)} generates * the same hash for the salt returned in the first call. * * @return Whether or not to test that the hash is the same for the same salt */ protected boolean testHashEqualityForSameSalt() { return true; } }