diff --git a/CHANGELOG b/CHANGELOG index c0fc0c9af..d1e63affc 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,7 @@ KeePassDX(3.4.0) * Show visual password strength indicator with entropy #631 #869 * Dynamically save password generator configuration #618 + * Add advanced password filters #1052 KeePassDX(3.3.3) * Fix shared otpauth link if database not open #1274 diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/GeneratePasswordDialogFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/GeneratePasswordDialogFragment.kt index b8b8bcbe8..85af6a720 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/GeneratePasswordDialogFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/GeneratePasswordDialogFragment.kt @@ -57,6 +57,8 @@ class GeneratePasswordDialogFragment : DatabaseDialogFragment() { private var specialsCompound: CompoundButton? = null private var bracketsCompound: CompoundButton? = null private var extendedCompound: CompoundButton? = null + private var atLeastOneCompound: CompoundButton? = null + private var excludeAmbiguousCompound: CompoundButton? = null override fun onAttach(context: Context) { super.onAttach(context) @@ -102,6 +104,8 @@ class GeneratePasswordDialogFragment : DatabaseDialogFragment() { specialsCompound = root?.findViewById(R.id.special_filter) bracketsCompound = root?.findViewById(R.id.brackets_filter) extendedCompound = root?.findViewById(R.id.extendedASCII_filter) + atLeastOneCompound = root?.findViewById(R.id.atLeastOne_filter) + excludeAmbiguousCompound = root?.findViewById(R.id.excludeAmbiguous_filter) mPasswordField = arguments?.getParcelable(KEY_PASSWORD_FIELD) @@ -134,6 +138,12 @@ class GeneratePasswordDialogFragment : DatabaseDialogFragment() { extendedCompound?.setOnCheckedChangeListener { _, _ -> fillPassword() } + atLeastOneCompound?.setOnCheckedChangeListener { _, _ -> + fillPassword() + } + excludeAmbiguousCompound?.setOnCheckedChangeListener { _, _ -> + fillPassword() + } var listenSlider = true var listenEditText = true @@ -228,6 +238,10 @@ class GeneratePasswordDialogFragment : DatabaseDialogFragment() { optionsSet.add(getString(R.string.value_password_brackets)) if (extendedCompound?.isChecked == true) optionsSet.add(getString(R.string.value_password_extended)) + if (atLeastOneCompound?.isChecked == true) + optionsSet.add(getString(R.string.value_password_atLeastOne)) + if (excludeAmbiguousCompound?.isChecked == true) + optionsSet.add(getString(R.string.value_password_excludeAmbiguous)) PreferencesUtil.setDefaultPasswordCharacters(it, optionsSet) PreferencesUtil.setDefaultPasswordLength(it, getPasswordLength()) } @@ -243,6 +257,8 @@ class GeneratePasswordDialogFragment : DatabaseDialogFragment() { specialsCompound?.isChecked = false bracketsCompound?.isChecked = false extendedCompound?.isChecked = false + atLeastOneCompound?.isChecked = false + excludeAmbiguousCompound?.isChecked = false context?.let { context -> PreferencesUtil.getDefaultPasswordCharacters(context)?.let { charSet -> @@ -257,6 +273,8 @@ class GeneratePasswordDialogFragment : DatabaseDialogFragment() { getString(R.string.value_password_special) -> specialsCompound?.isChecked = true getString(R.string.value_password_brackets) -> bracketsCompound?.isChecked = true getString(R.string.value_password_extended) -> extendedCompound?.isChecked = true + getString(R.string.value_password_atLeastOne) -> atLeastOneCompound?.isChecked = true + getString(R.string.value_password_excludeAmbiguous) -> excludeAmbiguousCompound?.isChecked = true } } } @@ -306,7 +324,9 @@ class GeneratePasswordDialogFragment : DatabaseDialogFragment() { spaceCompound?.isChecked == true, specialsCompound?.isChecked == true, bracketsCompound?.isChecked == true, - extendedCompound?.isChecked == true) + extendedCompound?.isChecked == true, + atLeastOneCompound?.isChecked == true, + excludeAmbiguousCompound?.isChecked == true) } catch (e: Exception) { Log.e(TAG, "Unable to generate a password", e) } diff --git a/app/src/main/java/com/kunzisoft/keepass/password/PasswordGenerator.kt b/app/src/main/java/com/kunzisoft/keepass/password/PasswordGenerator.kt index 23c8e3848..25a7e1b90 100644 --- a/app/src/main/java/com/kunzisoft/keepass/password/PasswordGenerator.kt +++ b/app/src/main/java/com/kunzisoft/keepass/password/PasswordGenerator.kt @@ -22,9 +22,201 @@ package com.kunzisoft.keepass.password import android.content.res.Resources import com.kunzisoft.keepass.R import java.security.SecureRandom +import java.util.* class PasswordGenerator(private val resources: Resources) { + @Throws(IllegalArgumentException::class) + fun generatePassword(length: Int, + upperCase: Boolean, + lowerCase: Boolean, + digits: Boolean, + minus: Boolean, + underline: Boolean, + space: Boolean, + specials: Boolean, + brackets: Boolean, + extended: Boolean, + atLeastOneFromEach: Boolean, + excludeAmbiguousChar: Boolean): String { + // Desired password length is 0 or less + if (length <= 0) { + throw IllegalArgumentException(resources.getString(R.string.error_wrong_length)) + } + + // No option has been checked + if (!upperCase + && !lowerCase + && !digits + && !minus + && !underline + && !space + && !specials + && !brackets + && !extended) { + throw IllegalArgumentException(resources.getString(R.string.error_pass_gen_type)) + } + + // Filter builder + val passwordFilters = PasswordFilters().apply { + this.length = length + if (upperCase) { + addFilter( + Filter( + if (excludeAmbiguousChar) UPPERCASE_NON_AMBIGUOUS_CHARS else UPPERCASE_CHARS, + if (atLeastOneFromEach) 1 else 0 + ) + ) + } + if (lowerCase) { + addFilter( + Filter( + if (excludeAmbiguousChar) LOWERCASE_NON_AMBIGUOUS_CHARS else LOWERCASE_CHARS, + if (atLeastOneFromEach) 1 else 0 + ) + ) + } + if (digits) { + addFilter( + Filter( + if (excludeAmbiguousChar) DIGIT_NON_AMBIGUOUS_CHARS else DIGIT_CHARS, + if (atLeastOneFromEach) 1 else 0 + ) + ) + } + if (minus) { + addFilter( + Filter( + MINUS_CHAR, + if (atLeastOneFromEach) 1 else 0 + ) + ) + } + if (underline) { + addFilter( + Filter( + UNDERLINE_CHAR, + if (atLeastOneFromEach) 1 else 0 + ) + ) + } + if (space) { + addFilter( + Filter( + SPACE_CHAR, + if (atLeastOneFromEach) 1 else 0 + ) + ) + } + if (specials) { + addFilter( + Filter( + SPECIAL_CHARS, + if (atLeastOneFromEach) 1 else 0 + ) + ) + } + if (brackets) { + addFilter( + Filter( + BRACKET_CHARS, + if (atLeastOneFromEach) 1 else 0 + ) + ) + } + if (extended) { + addFilter( + Filter( + extendedChars(), + if (atLeastOneFromEach) 1 else 0 + ) + ) + } + } + + return generateRandomString(SecureRandom(), passwordFilters) + } + + private fun generateRandomString(random: Random, passwordFilters: PasswordFilters): String { + val randomString = StringBuilder() + + // Allocate appropriate memory for the password. + var requiredCharactersLeft = passwordFilters.getRequiredCharactersLeft() + + // Build the password. + for (i in 0 until passwordFilters.length) { + val selectableChars: String = if (requiredCharactersLeft < passwordFilters.length - i) { + // choose from any group at random + passwordFilters.getSelectableChars() + } else { + // choose only from a group that we need to satisfy a minimum for. + passwordFilters.getSelectableCharsForNeed() + } + + // Now that the string is built, get the next random character. + val selectableCharsMaxIndex = selectableChars.length - 1 + val randomSelectableCharsIndex = if (selectableCharsMaxIndex > 0) random.nextInt(selectableCharsMaxIndex) else 0 + val nextChar = selectableChars[randomSelectableCharsIndex] + + // Put at random position + val randomStringMaxIndex = randomString.length - 1 + val randomStringIndex = if (randomStringMaxIndex > 0) random.nextInt(randomStringMaxIndex) else 0 + randomString.insert(randomStringIndex, nextChar) + + // Now figure out where it came from, and decrement the appropriate minimum value + passwordFilters.getFilterThatContainsChar(nextChar)?.let { + if (it.minCharsNeeded > 0) { + it.minCharsNeeded-- + requiredCharactersLeft-- + } + } + } + return randomString.toString() + } + + private data class Filter(var chars: String, + var minCharsNeeded: Int) + + private class PasswordFilters { + var length: Int = 0 + val filters = mutableListOf() + + fun addFilter(filter: Filter) { + filters.add(filter) + } + + fun getRequiredCharactersLeft(): Int { + var charsRequired = 0 + filters.forEach { + charsRequired += it.minCharsNeeded + } + return charsRequired + } + + fun getSelectableChars(): String { + val stringBuilder = StringBuilder() + filters.forEach { + stringBuilder.append(it.chars) + } + return stringBuilder.toString() + } + + fun getFilterThatContainsChar(char: Char): Filter? { + return filters.find { it.chars.contains(char) } + } + + fun getSelectableCharsForNeed(): String { + val selectableChars = StringBuilder() + // choose only from a group that we need to satisfy a minimum for. + filters.forEach { + if (it.minCharsNeeded > 0) { + selectableChars.append(it.chars) + } + } + return selectableChars.toString() + } + } + // From KeePassXC code https://github.com/keepassxreboot/keepassxc/pull/538 private fun extendedChars(): String { val charSet = StringBuilder() @@ -47,105 +239,13 @@ class PasswordGenerator(private val resources: Resources) { return charSet.toString() } - @Throws(IllegalArgumentException::class) - fun generatePassword(length: Int, - upperCase: Boolean, - lowerCase: Boolean, - digits: Boolean, - minus: Boolean, - underline: Boolean, - space: Boolean, - specials: Boolean, - brackets: Boolean, - extended: Boolean): String { - // Desired password length is 0 or less - if (length <= 0) { - throw IllegalArgumentException(resources.getString(R.string.error_wrong_length)) - } - - // No option has been checked - if (!upperCase - && !lowerCase - && !digits - && !minus - && !underline - && !space - && !specials - && !brackets - && !extended) { - throw IllegalArgumentException(resources.getString(R.string.error_pass_gen_type)) - } - - val characterSet = getCharacterSet( - upperCase, - lowerCase, - digits, - minus, - underline, - space, - specials, - brackets, - extended) - - val size = characterSet.length - - val buffer = StringBuilder() - - val random = SecureRandom() // use more secure variant of Random! - if (size > 0) { - for (i in 0 until length) { - buffer.append(characterSet[random.nextInt(size)]) - } - } - return buffer.toString() - } - - private fun getCharacterSet(upperCase: Boolean, - lowerCase: Boolean, - digits: Boolean, - minus: Boolean, - underline: Boolean, - space: Boolean, - specials: Boolean, - brackets: Boolean, - extended: Boolean): String { - val charSet = StringBuilder() - - if (upperCase) { - charSet.append(UPPERCASE_CHARS) - } - if (lowerCase) { - charSet.append(LOWERCASE_CHARS) - } - if (digits) { - charSet.append(DIGIT_CHARS) - } - if (minus) { - charSet.append(MINUS_CHAR) - } - if (underline) { - charSet.append(UNDERLINE_CHAR) - } - if (space) { - charSet.append(SPACE_CHAR) - } - if (specials) { - charSet.append(SPECIAL_CHARS) - } - if (brackets) { - charSet.append(BRACKET_CHARS) - } - if (extended) { - charSet.append(extendedChars()) - } - - return charSet.toString() - } - companion object { private const val UPPERCASE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + private const val UPPERCASE_NON_AMBIGUOUS_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ" private const val LOWERCASE_CHARS = "abcdefghijklmnopqrstuvwxyz" + private const val LOWERCASE_NON_AMBIGUOUS_CHARS = "abcdefghjkmnpqrstuvwxyz" private const val DIGIT_CHARS = "0123456789" + private const val DIGIT_NON_AMBIGUOUS_CHARS = "23456789" private const val MINUS_CHAR = "-" private const val UNDERLINE_CHAR = "_" private const val SPACE_CHAR = " " diff --git a/app/src/main/res/layout/fragment_generate_password.xml b/app/src/main/res/layout/fragment_generate_password.xml index 25c4c0e57..68592b9b1 100644 --- a/app/src/main/res/layout/fragment_generate_password.xml +++ b/app/src/main/res/layout/fragment_generate_password.xml @@ -39,30 +39,30 @@ android:layout_marginTop="@dimen/card_view_margin_vertical" android:layout_marginBottom="@dimen/card_view_margin_vertical" app:cardCornerRadius="4dp"> - + android:layout_toStartOf="@+id/password_copy_button" + android:layout_toLeftOf="@+id/password_copy_button" /> - + + + + + + + + + +