blob: 39e1f0dc2d2c5d42ea9a09f757b30d2596c7c8f0 [file] [log] [blame]
/*
* Copyright (C) 2016 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.app.admin;
import static android.app.admin.DevicePolicyManager.MAX_PASSWORD_LENGTH;
import static android.app.admin.DevicePolicyManager.PASSWORD_COMPLEXITY_HIGH;
import static android.app.admin.DevicePolicyManager.PASSWORD_COMPLEXITY_LOW;
import static android.app.admin.DevicePolicyManager.PASSWORD_COMPLEXITY_MEDIUM;
import static android.app.admin.DevicePolicyManager.PASSWORD_COMPLEXITY_NONE;
import static android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_NUMERIC_COMPLEX;
import static android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_SOMETHING;
import static android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_UNSPECIFIED;
import static com.android.internal.widget.LockPatternUtils.CREDENTIAL_TYPE_NONE;
import static com.android.internal.widget.LockPatternUtils.CREDENTIAL_TYPE_PASSWORD;
import static com.android.internal.widget.LockPatternUtils.CREDENTIAL_TYPE_PATTERN;
import static com.android.internal.widget.LockPatternUtils.MIN_LOCK_PASSWORD_SIZE;
import static com.android.internal.widget.PasswordValidationError.CONTAINS_INVALID_CHARACTERS;
import static com.android.internal.widget.PasswordValidationError.CONTAINS_SEQUENCE;
import static com.android.internal.widget.PasswordValidationError.NOT_ENOUGH_DIGITS;
import static com.android.internal.widget.PasswordValidationError.NOT_ENOUGH_LETTERS;
import static com.android.internal.widget.PasswordValidationError.NOT_ENOUGH_LOWER_CASE;
import static com.android.internal.widget.PasswordValidationError.NOT_ENOUGH_NON_DIGITS;
import static com.android.internal.widget.PasswordValidationError.NOT_ENOUGH_NON_LETTER;
import static com.android.internal.widget.PasswordValidationError.NOT_ENOUGH_SYMBOLS;
import static com.android.internal.widget.PasswordValidationError.NOT_ENOUGH_UPPER_CASE;
import static com.android.internal.widget.PasswordValidationError.TOO_LONG;
import static com.android.internal.widget.PasswordValidationError.TOO_SHORT;
import static com.android.internal.widget.PasswordValidationError.WEAK_CREDENTIAL_TYPE;
import android.annotation.IntDef;
import android.annotation.NonNull;
import android.app.admin.DevicePolicyManager.PasswordComplexity;
import android.os.Parcel;
import android.os.Parcelable;
import android.util.Log;
import com.android.internal.widget.LockPatternUtils.CredentialType;
import com.android.internal.widget.LockscreenCredential;
import com.android.internal.widget.PasswordValidationError;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
/**
* A class that represents the metrics of a credential that are used to decide whether or not a
* credential meets the requirements.
*
* {@hide}
*/
public final class PasswordMetrics implements Parcelable {
private static final String TAG = "PasswordMetrics";
// Maximum allowed number of repeated or ordered characters in a sequence before we'll
// consider it a complex PIN/password.
public static final int MAX_ALLOWED_SEQUENCE = 3;
// One of CREDENTIAL_TYPE_NONE, CREDENTIAL_TYPE_PATTERN or CREDENTIAL_TYPE_PASSWORD.
// Note that this class still uses CREDENTIAL_TYPE_PASSWORD to represent both numeric PIN
// and alphabetic password. This is OK as long as this definition is only used internally,
// and the value never gets mixed up with credential types from other parts of the framework.
// TODO: fix this (ideally after we move logic to PasswordPolicy)
public @CredentialType int credType;
// Fields below only make sense when credType is PASSWORD.
public int length = 0;
public int letters = 0;
public int upperCase = 0;
public int lowerCase = 0;
public int numeric = 0;
public int symbols = 0;
public int nonLetter = 0;
public int nonNumeric = 0;
// MAX_VALUE is the most relaxed value, any sequence is ok, e.g. 123456789. 4 would forbid it.
public int seqLength = Integer.MAX_VALUE;
public PasswordMetrics(int credType) {
this.credType = credType;
}
public PasswordMetrics(int credType , int length, int letters, int upperCase, int lowerCase,
int numeric, int symbols, int nonLetter, int nonNumeric, int seqLength) {
this.credType = credType;
this.length = length;
this.letters = letters;
this.upperCase = upperCase;
this.lowerCase = lowerCase;
this.numeric = numeric;
this.symbols = symbols;
this.nonLetter = nonLetter;
this.nonNumeric = nonNumeric;
this.seqLength = seqLength;
}
private PasswordMetrics(PasswordMetrics other) {
this(other.credType, other.length, other.letters, other.upperCase, other.lowerCase,
other.numeric, other.symbols, other.nonLetter, other.nonNumeric, other.seqLength);
}
/**
* Returns {@code complexityLevel} or {@link DevicePolicyManager#PASSWORD_COMPLEXITY_NONE}
* if {@code complexityLevel} is not valid.
*
* TODO: move to PasswordPolicy
*/
@PasswordComplexity
public static int sanitizeComplexityLevel(@PasswordComplexity int complexityLevel) {
switch (complexityLevel) {
case PASSWORD_COMPLEXITY_HIGH:
case PASSWORD_COMPLEXITY_MEDIUM:
case PASSWORD_COMPLEXITY_LOW:
case PASSWORD_COMPLEXITY_NONE:
return complexityLevel;
default:
Log.w(TAG, "Invalid password complexity used: " + complexityLevel);
return PASSWORD_COMPLEXITY_NONE;
}
}
private static boolean hasInvalidCharacters(byte[] password) {
// Allow non-control Latin-1 characters only.
for (byte b : password) {
char c = (char) b;
if (c < 32 || c > 127) {
return true;
}
}
return false;
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeInt(credType);
dest.writeInt(length);
dest.writeInt(letters);
dest.writeInt(upperCase);
dest.writeInt(lowerCase);
dest.writeInt(numeric);
dest.writeInt(symbols);
dest.writeInt(nonLetter);
dest.writeInt(nonNumeric);
dest.writeInt(seqLength);
}
public static final @NonNull Parcelable.Creator<PasswordMetrics> CREATOR
= new Parcelable.Creator<PasswordMetrics>() {
@Override
public PasswordMetrics createFromParcel(Parcel in) {
int credType = in.readInt();
int length = in.readInt();
int letters = in.readInt();
int upperCase = in.readInt();
int lowerCase = in.readInt();
int numeric = in.readInt();
int symbols = in.readInt();
int nonLetter = in.readInt();
int nonNumeric = in.readInt();
int seqLength = in.readInt();
return new PasswordMetrics(credType, length, letters, upperCase, lowerCase,
numeric, symbols, nonLetter, nonNumeric, seqLength);
}
@Override
public PasswordMetrics[] newArray(int size) {
return new PasswordMetrics[size];
}
};
/**
* Returns the {@code PasswordMetrics} for a given credential.
*
* If the credential is a pin or a password, equivalent to {@link #computeForPassword(byte[])}.
* {@code credential} cannot be null when {@code type} is
* {@link com.android.internal.widget.LockPatternUtils#CREDENTIAL_TYPE_PASSWORD}.
*/
public static PasswordMetrics computeForCredential(LockscreenCredential credential) {
if (credential.isPassword() || credential.isPin()) {
return PasswordMetrics.computeForPassword(credential.getCredential());
} else if (credential.isPattern()) {
return new PasswordMetrics(CREDENTIAL_TYPE_PATTERN);
} else if (credential.isNone()) {
return new PasswordMetrics(CREDENTIAL_TYPE_NONE);
} else {
throw new IllegalArgumentException("Unknown credential type " + credential.getType());
}
}
/**
* Returns the {@code PasswordMetrics} for a given password
*/
public static PasswordMetrics computeForPassword(@NonNull byte[] password) {
// Analyse the characters used
int letters = 0;
int upperCase = 0;
int lowerCase = 0;
int numeric = 0;
int symbols = 0;
int nonLetter = 0;
int nonNumeric = 0;
final int length = password.length;
for (byte b : password) {
switch (categoryChar((char) b)) {
case CHAR_LOWER_CASE:
letters++;
lowerCase++;
nonNumeric++;
break;
case CHAR_UPPER_CASE:
letters++;
upperCase++;
nonNumeric++;
break;
case CHAR_DIGIT:
numeric++;
nonLetter++;
break;
case CHAR_SYMBOL:
symbols++;
nonLetter++;
nonNumeric++;
break;
}
}
final int seqLength = maxLengthSequence(password);
return new PasswordMetrics(CREDENTIAL_TYPE_PASSWORD, length, letters, upperCase, lowerCase,
numeric, symbols, nonLetter, nonNumeric, seqLength);
}
/**
* Returns the maximum length of a sequential characters. A sequence is defined as
* monotonically increasing characters with a constant interval or the same character repeated.
*
* For example:
* maxLengthSequence("1234") == 4
* maxLengthSequence("13579") == 5
* maxLengthSequence("1234abc") == 4
* maxLengthSequence("aabc") == 3
* maxLengthSequence("qwertyuio") == 1
* maxLengthSequence("@ABC") == 3
* maxLengthSequence(";;;;") == 4 (anything that repeats)
* maxLengthSequence(":;<=>") == 1 (ordered, but not composed of alphas or digits)
*
* @param bytes the pass
* @return the number of sequential letters or digits
*/
public static int maxLengthSequence(@NonNull byte[] bytes) {
if (bytes.length == 0) return 0;
char previousChar = (char) bytes[0];
@CharacterCatagory int category = categoryChar(previousChar); //current sequence category
int diff = 0; //difference between two consecutive characters
boolean hasDiff = false; //if we are currently targeting a sequence
int maxLength = 0; //maximum length of a sequence already found
int startSequence = 0; //where the current sequence started
for (int current = 1; current < bytes.length; current++) {
char currentChar = (char) bytes[current];
@CharacterCatagory int categoryCurrent = categoryChar(currentChar);
int currentDiff = (int) currentChar - (int) previousChar;
if (categoryCurrent != category || Math.abs(currentDiff) > maxDiffCategory(category)) {
maxLength = Math.max(maxLength, current - startSequence);
startSequence = current;
hasDiff = false;
category = categoryCurrent;
}
else {
if(hasDiff && currentDiff != diff) {
maxLength = Math.max(maxLength, current - startSequence);
startSequence = current - 1;
}
diff = currentDiff;
hasDiff = true;
}
previousChar = currentChar;
}
maxLength = Math.max(maxLength, bytes.length - startSequence);
return maxLength;
}
@Retention(RetentionPolicy.SOURCE)
@IntDef(prefix = { "CHAR_" }, value = {
CHAR_UPPER_CASE,
CHAR_LOWER_CASE,
CHAR_DIGIT,
CHAR_SYMBOL
})
private @interface CharacterCatagory {}
private static final int CHAR_LOWER_CASE = 0;
private static final int CHAR_UPPER_CASE = 1;
private static final int CHAR_DIGIT = 2;
private static final int CHAR_SYMBOL = 3;
@CharacterCatagory
private static int categoryChar(char c) {
if ('a' <= c && c <= 'z') return CHAR_LOWER_CASE;
if ('A' <= c && c <= 'Z') return CHAR_UPPER_CASE;
if ('0' <= c && c <= '9') return CHAR_DIGIT;
return CHAR_SYMBOL;
}
private static int maxDiffCategory(@CharacterCatagory int category) {
switch (category) {
case CHAR_LOWER_CASE:
case CHAR_UPPER_CASE:
return 1;
case CHAR_DIGIT:
return 10;
default:
return 0;
}
}
/**
* Returns the weakest metrics that is stricter or equal to all given metrics.
*
* TODO: move to PasswordPolicy
*/
public static PasswordMetrics merge(List<PasswordMetrics> metrics) {
PasswordMetrics result = new PasswordMetrics(CREDENTIAL_TYPE_NONE);
for (PasswordMetrics m : metrics) {
result.maxWith(m);
}
return result;
}
/**
* Makes current metric at least as strong as {@code other} in every criterion.
*
* TODO: move to PasswordPolicy
*/
public void maxWith(PasswordMetrics other) {
credType = Math.max(credType, other.credType);
if (credType != CREDENTIAL_TYPE_PASSWORD) {
return;
}
length = Math.max(length, other.length);
letters = Math.max(letters, other.letters);
upperCase = Math.max(upperCase, other.upperCase);
lowerCase = Math.max(lowerCase, other.lowerCase);
numeric = Math.max(numeric, other.numeric);
symbols = Math.max(symbols, other.symbols);
nonLetter = Math.max(nonLetter, other.nonLetter);
nonNumeric = Math.max(nonNumeric, other.nonNumeric);
seqLength = Math.min(seqLength, other.seqLength);
}
/**
* Returns minimum password quality for a given complexity level.
*
* TODO: this function is used for determining allowed credential types, so it should return
* credential type rather than 'quality'.
*
* TODO: move to PasswordPolicy
*/
public static int complexityLevelToMinQuality(int complexity) {
switch (complexity) {
case PASSWORD_COMPLEXITY_HIGH:
case PASSWORD_COMPLEXITY_MEDIUM:
return PASSWORD_QUALITY_NUMERIC_COMPLEX;
case PASSWORD_COMPLEXITY_LOW:
return PASSWORD_QUALITY_SOMETHING;
case PASSWORD_COMPLEXITY_NONE:
default:
return PASSWORD_QUALITY_UNSPECIFIED;
}
}
/**
* Enum representing requirements for each complexity level.
*
* TODO: move to PasswordPolicy
*/
private enum ComplexityBucket {
// Keep ordered high -> low.
BUCKET_HIGH(PASSWORD_COMPLEXITY_HIGH) {
@Override
boolean canHaveSequence() {
return false;
}
@Override
int getMinimumLength(boolean containsNonNumeric) {
return containsNonNumeric ? 6 : 8;
}
@Override
boolean allowsNumericPassword() {
return false;
}
@Override
boolean allowsCredType(int credType) {
return credType == CREDENTIAL_TYPE_PASSWORD;
}
},
BUCKET_MEDIUM(PASSWORD_COMPLEXITY_MEDIUM) {
@Override
boolean canHaveSequence() {
return false;
}
@Override
int getMinimumLength(boolean containsNonNumeric) {
return 4;
}
@Override
boolean allowsNumericPassword() {
return false;
}
@Override
boolean allowsCredType(int credType) {
return credType == CREDENTIAL_TYPE_PASSWORD;
}
},
BUCKET_LOW(PASSWORD_COMPLEXITY_LOW) {
@Override
boolean canHaveSequence() {
return true;
}
@Override
int getMinimumLength(boolean containsNonNumeric) {
return 0;
}
@Override
boolean allowsNumericPassword() {
return true;
}
@Override
boolean allowsCredType(int credType) {
return credType != CREDENTIAL_TYPE_NONE;
}
},
BUCKET_NONE(PASSWORD_COMPLEXITY_NONE) {
@Override
boolean canHaveSequence() {
return true;
}
@Override
int getMinimumLength(boolean containsNonNumeric) {
return 0;
}
@Override
boolean allowsNumericPassword() {
return true;
}
@Override
boolean allowsCredType(int credType) {
return true;
}
};
int mComplexityLevel;
abstract boolean canHaveSequence();
abstract int getMinimumLength(boolean containsNonNumeric);
abstract boolean allowsNumericPassword();
abstract boolean allowsCredType(int credType);
ComplexityBucket(int complexityLevel) {
this.mComplexityLevel = complexityLevel;
}
static ComplexityBucket forComplexity(int complexityLevel) {
for (ComplexityBucket bucket : values()) {
if (bucket.mComplexityLevel == complexityLevel) {
return bucket;
}
}
throw new IllegalArgumentException("Invalid complexity level: " + complexityLevel);
}
}
/**
* Returns whether current metrics satisfies a given complexity bucket.
*
* TODO: move inside ComplexityBucket.
*/
private boolean satisfiesBucket(ComplexityBucket bucket) {
if (!bucket.allowsCredType(credType)) {
return false;
}
if (credType != CREDENTIAL_TYPE_PASSWORD) {
return true;
}
return (bucket.canHaveSequence() || seqLength <= MAX_ALLOWED_SEQUENCE)
&& length >= bucket.getMinimumLength(nonNumeric > 0 /* hasNonNumeric */);
}
/**
* Returns the maximum complexity level satisfied by password with this metrics.
*
* TODO: move inside ComplexityBucket.
*/
public int determineComplexity() {
for (ComplexityBucket bucket : ComplexityBucket.values()) {
if (satisfiesBucket(bucket)) {
return bucket.mComplexityLevel;
}
}
throw new IllegalStateException("Failed to figure out complexity for a given metrics");
}
/**
* Validates password against minimum metrics and complexity.
*
* @param adminMetrics - minimum metrics to satisfy admin requirements.
* @param minComplexity - minimum complexity imposed by the requester.
* @param isPin - whether it is PIN that should be only digits
* @param password - password to validate.
* @return a list of password validation errors. An empty list means the password is OK.
*
* TODO: move to PasswordPolicy
*/
public static List<PasswordValidationError> validatePassword(
PasswordMetrics adminMetrics, int minComplexity, boolean isPin, byte[] password) {
if (hasInvalidCharacters(password)) {
return Collections.singletonList(
new PasswordValidationError(CONTAINS_INVALID_CHARACTERS, 0));
}
final PasswordMetrics enteredMetrics = computeForPassword(password);
return validatePasswordMetrics(adminMetrics, minComplexity, isPin, enteredMetrics);
}
/**
* Validates password metrics against minimum metrics and complexity
*
* @param adminMetrics - minimum metrics to satisfy admin requirements.
* @param minComplexity - minimum complexity imposed by the requester.
* @param isPin - whether it is PIN that should be only digits
* @param actualMetrics - metrics for password to validate.
* @return a list of password validation errors. An empty list means the password is OK.
*
* TODO: move to PasswordPolicy
*/
public static List<PasswordValidationError> validatePasswordMetrics(
PasswordMetrics adminMetrics, int minComplexity, boolean isPin,
PasswordMetrics actualMetrics) {
final ComplexityBucket bucket = ComplexityBucket.forComplexity(minComplexity);
// Make sure credential type is satisfactory.
// TODO: stop relying on credential type ordering.
if (actualMetrics.credType < adminMetrics.credType
|| !bucket.allowsCredType(actualMetrics.credType)) {
return Collections.singletonList(new PasswordValidationError(WEAK_CREDENTIAL_TYPE, 0));
}
// TODO: this needs to be modified if CREDENTIAL_TYPE_PIN is added.
if (actualMetrics.credType != CREDENTIAL_TYPE_PASSWORD) {
return Collections.emptyList(); // Nothing to check for pattern or none.
}
if (isPin && actualMetrics.nonNumeric > 0) {
return Collections.singletonList(
new PasswordValidationError(CONTAINS_INVALID_CHARACTERS, 0));
}
final ArrayList<PasswordValidationError> result = new ArrayList<>();
if (actualMetrics.length > MAX_PASSWORD_LENGTH) {
result.add(new PasswordValidationError(TOO_LONG, MAX_PASSWORD_LENGTH));
}
final PasswordMetrics minMetrics = applyComplexity(adminMetrics, isPin, bucket);
// Clamp required length between maximum and minimum valid values.
minMetrics.length = Math.min(MAX_PASSWORD_LENGTH,
Math.max(minMetrics.length, MIN_LOCK_PASSWORD_SIZE));
minMetrics.removeOverlapping();
comparePasswordMetrics(minMetrics, actualMetrics, result);
return result;
}
/**
* TODO: move to PasswordPolicy
*/
private static void comparePasswordMetrics(PasswordMetrics minMetrics,
PasswordMetrics actualMetrics, ArrayList<PasswordValidationError> result) {
if (actualMetrics.length < minMetrics.length) {
result.add(new PasswordValidationError(TOO_SHORT, minMetrics.length));
}
if (actualMetrics.letters < minMetrics.letters) {
result.add(new PasswordValidationError(NOT_ENOUGH_LETTERS, minMetrics.letters));
}
if (actualMetrics.upperCase < minMetrics.upperCase) {
result.add(new PasswordValidationError(NOT_ENOUGH_UPPER_CASE, minMetrics.upperCase));
}
if (actualMetrics.lowerCase < minMetrics.lowerCase) {
result.add(new PasswordValidationError(NOT_ENOUGH_LOWER_CASE, minMetrics.lowerCase));
}
if (actualMetrics.numeric < minMetrics.numeric) {
result.add(new PasswordValidationError(NOT_ENOUGH_DIGITS, minMetrics.numeric));
}
if (actualMetrics.symbols < minMetrics.symbols) {
result.add(new PasswordValidationError(NOT_ENOUGH_SYMBOLS, minMetrics.symbols));
}
if (actualMetrics.nonLetter < minMetrics.nonLetter) {
result.add(new PasswordValidationError(NOT_ENOUGH_NON_LETTER, minMetrics.nonLetter));
}
if (actualMetrics.nonNumeric < minMetrics.nonNumeric) {
result.add(new PasswordValidationError(NOT_ENOUGH_NON_DIGITS, minMetrics.nonNumeric));
}
if (actualMetrics.seqLength > minMetrics.seqLength) {
result.add(new PasswordValidationError(CONTAINS_SEQUENCE, 0));
}
}
/**
* Drop requirements that are superseded by others, e.g. if it is required to have 5 upper case
* letters and 5 lower case letters, there is no need to require minimum number of letters to
* be 10 since it will be fulfilled once upper and lower case requirements are fulfilled.
*
* TODO: move to PasswordPolicy
*/
private void removeOverlapping() {
// upperCase + lowerCase can override letters
final int indirectLetters = upperCase + lowerCase;
// numeric + symbols can override nonLetter
final int indirectNonLetter = numeric + symbols;
// letters + symbols can override nonNumeric
final int effectiveLetters = Math.max(letters, indirectLetters);
final int indirectNonNumeric = effectiveLetters + symbols;
// letters + nonLetters can override length
// numeric + nonNumeric can also override length, so max it with previous.
final int effectiveNonLetter = Math.max(nonLetter, indirectNonLetter);
final int effectiveNonNumeric = Math.max(nonNumeric, indirectNonNumeric);
final int indirectLength = Math.max(effectiveLetters + effectiveNonLetter,
numeric + effectiveNonNumeric);
if (indirectLetters >= letters) {
letters = 0;
}
if (indirectNonLetter >= nonLetter) {
nonLetter = 0;
}
if (indirectNonNumeric >= nonNumeric) {
nonNumeric = 0;
}
if (indirectLength >= length) {
length = 0;
}
}
/**
* Combine minimum metrics, set by admin, complexity set by the requester and actual entered
* password metrics to get resulting minimum metrics that the password has to satisfy. Always
* returns a new PasswordMetrics object.
*
* TODO: move to PasswordPolicy
*/
public static PasswordMetrics applyComplexity(
PasswordMetrics adminMetrics, boolean isPin, int complexity) {
return applyComplexity(adminMetrics, isPin, ComplexityBucket.forComplexity(complexity));
}
private static PasswordMetrics applyComplexity(
PasswordMetrics adminMetrics, boolean isPin, ComplexityBucket bucket) {
final PasswordMetrics minMetrics = new PasswordMetrics(adminMetrics);
if (!bucket.canHaveSequence()) {
minMetrics.seqLength = Math.min(minMetrics.seqLength, MAX_ALLOWED_SEQUENCE);
}
minMetrics.length = Math.max(minMetrics.length, bucket.getMinimumLength(!isPin));
if (!isPin && !bucket.allowsNumericPassword()) {
minMetrics.nonNumeric = Math.max(minMetrics.nonNumeric, 1);
}
return minMetrics;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final PasswordMetrics that = (PasswordMetrics) o;
return credType == that.credType
&& length == that.length
&& letters == that.letters
&& upperCase == that.upperCase
&& lowerCase == that.lowerCase
&& numeric == that.numeric
&& symbols == that.symbols
&& nonLetter == that.nonLetter
&& nonNumeric == that.nonNumeric
&& seqLength == that.seqLength;
}
@Override
public int hashCode() {
return Objects.hash(credType, length, letters, upperCase, lowerCase, numeric, symbols,
nonLetter, nonNumeric, seqLength);
}
}