Given the following class:
package software.visionary.identifr;
import software.visionary.identifr.api.Authenticatable;
import software.visionary.identifr.api.Credentials;
import javax.crypto.*;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.PBEParameterSpec;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.util.Arrays;
import java.util.Objects;
public final class PasswordCredentials implements Credentials {
private final Authenticatable owner;
private final byte[] value;
private final SecretKey key;
public PasswordCredentials(final Authenticatable human, final String password) {
if (Objects.requireNonNull(password).trim().isEmpty()) {
throw new IllegalArgumentException("Invalid password");
}
this.owner = Objects.requireNonNull(human);
this.key = asSecretKey(password);
this.value = this.key.getEncoded();
}
private SecretKey asSecretKey(final String password) {
try {
final PBEKeySpec pbeKeySpec = new PBEKeySpec(password.toCharArray());
final SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance("PBEWithMD5AndTripleDES");
return secretKeyFactory.generateSecret(pbeKeySpec);
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new RuntimeException(e);
}
}
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) {
return false;
}
final PasswordCredentials that = (PasswordCredentials) o;
return owner.equals(that.owner) &&
Arrays.equals(value, that.value);
}
@Override
public int hashCode() {
return Objects.hash(owner, value);
}
}
And the following tests:
package software.visionary.identifr;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import software.visionary.Randomizr;
import software.visionary.identifr.api.Authenticatable;
import software.visionary.identifr.api.Credentials;
import java.util.UUID;
final class PasswordCredentialsTest {
@Test
void rejectsNullOwner() {
final Authenticatable owner = null;
final String password = Randomizr.INSTANCE.createRandomPassword();
Assertions.assertThrows(NullPointerException.class, () -> new PasswordCredentials(owner, password));
}
@Test
void rejectsNullPassword() {
final Authenticatable owner = new Authenticatable() {
@Override
public Credentials getCredentials() {
return null;
}
@Override
public UUID getID() {
return null;
}
};
final String password = null;
Assertions.assertThrows(NullPointerException.class, () -> new PasswordCredentials(owner, password));
}
@Test
void rejectsEmptyPassword() {
final Authenticatable owner = new Authenticatable() {
@Override
public Credentials getCredentials() {
return null;
}
@Override
public UUID getID() {
return null;
}
};
final String password = "";
Assertions.assertThrows(IllegalArgumentException.class, () -> new PasswordCredentials(owner, password));
}
@Test
void rejectsWhitespacePassword() {
final Authenticatable owner = new Authenticatable() {
@Override
public Credentials getCredentials() {
return null;
}
@Override
public UUID getID() {
return null;
}
};
final String password = "\t\t\n\n\n";
Assertions.assertThrows(IllegalArgumentException.class, () -> new PasswordCredentials(owner, password));
}
@Test
void hashCodeIsImplementedCorrectly() {
final Authenticatable owner = Fixtures.randomAuthenticatable();
final String password = Randomizr.INSTANCE.createRandomPassword();
final PasswordCredentials creds = new PasswordCredentials(owner, password);
final int firstHash = creds.hashCode();
final int secondHash = creds.hashCode();
Assertions.assertEquals(firstHash, secondHash);
final PasswordCredentials same = new PasswordCredentials(owner, password);
Assertions.assertEquals(creds.hashCode(), same.hashCode());
final PasswordCredentials different = new PasswordCredentials(owner, Randomizr.INSTANCE.createRandomPassword());
Assertions.assertNotEquals(firstHash, different.hashCode());
}
@Test
void equalsIsImplementedCorrectly() {
final Authenticatable owner = Fixtures.randomAuthenticatable();
final String password = Randomizr.INSTANCE.createRandomPassword();
final PasswordCredentials creds = new PasswordCredentials(owner, password);
Assertions.assertTrue(creds.equals(creds));
final PasswordCredentials same = new PasswordCredentials(owner, password);
Assertions.assertTrue(creds.equals(same));
Assertions.assertTrue(same.equals(creds));
final PasswordCredentials different = new PasswordCredentials(owner, Randomizr.INSTANCE.createRandomPassword());
Assertions.assertFalse(creds.equals(different));
Assertions.assertFalse(different.equals(creds));
}
}
hashCodeIsImplementedCorrectly()
is failing in a way I don't expect: two objects that satisfy the equals
contract are returning different hashcodes. This seems in direct violation of the JavaDoc:
If two objects are equal according to the equals(Object) method, then calling the hashCode method on each of the two objects must produce the same integer result.
I'm just using Objects.hash
in the recommended, IDE auto-generated manner...
This method is useful for implementing Object.hashCode() on objects containing multiple fields. For example, if an object that has three fields, x, y, and z, one could write:
@Override public int hashCode() { return Objects.hash(x, y, z); }
Am I missing something obvious? I haven't had this problem before, and written lots of unit tests for equals()/hashCode().
I shudder to think, but in case it's relevant...
java --version
openjdk 11.0.5 2019-10-15
OpenJDK Runtime Environment (build 11.0.5+10-post-Ubuntu-0ubuntu1.119.04)
OpenJDK 64-Bit Server VM (build 11.0.5+10-post-Ubuntu-0ubuntu1.119.04, mixed mode, sharing)