From abc62c2b6b9d7106eb9f53f4994f086835602e57 Mon Sep 17 00:00:00 2001 From: Aditya Wasan Date: Thu, 13 Jan 2022 22:13:53 +0530 Subject: Refactor randomized password generator into a separate module (#1663) --- app/build.gradle.kts | 1 + .../ui/dialogs/PasswordGeneratorDialogFragment.kt | 69 +++++--- .../msfjarvis/aps/util/pwgen/PasswordGenerator.kt | 138 ---------------- .../aps/util/pwgen/RandomNumberGenerator.kt | 31 ---- .../aps/util/pwgen/RandomPasswordGenerator.kt | 48 ------ .../aps/util/pwgen/RandomPhonemesGenerator.kt | 180 --------------------- app/src/main/res/values/strings.xml | 1 + ...-password-store.binary-compatibility.gradle.kts | 1 + passgen/random/.gitignore | 1 + passgen/random/build.gradle.kts | 4 + .../aps/passgen/random/PasswordGenerator.kt | 100 ++++++++++++ .../passgen/random/PasswordGeneratorException.kt | 10 ++ .../msfjarvis/aps/passgen/random/PasswordOption.kt | 10 ++ .../aps/passgen/random/RandomNumberGenerator.kt | 31 ++++ .../aps/passgen/random/RandomPasswordGenerator.kt | 48 ++++++ .../aps/passgen/random/RandomPhonemesGenerator.kt | 180 +++++++++++++++++++++ .../aps/passgen/random/util/Extensions.kt | 11 ++ settings.gradle.kts | 2 + 18 files changed, 450 insertions(+), 416 deletions(-) delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/pwgen/PasswordGenerator.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomNumberGenerator.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPasswordGenerator.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPhonemesGenerator.kt create mode 100644 passgen/random/.gitignore create mode 100644 passgen/random/build.gradle.kts create mode 100644 passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/PasswordGenerator.kt create mode 100644 passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/PasswordGeneratorException.kt create mode 100644 passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/PasswordOption.kt create mode 100644 passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/RandomNumberGenerator.kt create mode 100644 passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/RandomPasswordGenerator.kt create mode 100644 passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/RandomPhonemesGenerator.kt create mode 100644 passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/util/Extensions.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 443121cf..e171a814 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -79,6 +79,7 @@ dependencies { implementation(projects.formatCommon) implementation(projects.openpgpKtx) implementation(projects.passgen.diceware) + implementation(projects.passgen.random) implementation(libs.androidx.activity.ktx) implementation(libs.androidx.appcompat) implementation(libs.androidx.autofill) diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt index ab51cc4e..a21e9a6a 100644 --- a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt +++ b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt @@ -14,6 +14,7 @@ import android.widget.EditText import android.widget.Toast import androidx.annotation.IdRes import androidx.appcompat.widget.AppCompatTextView +import androidx.core.content.edit import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment import androidx.fragment.app.setFragmentResult @@ -23,11 +24,12 @@ import com.github.michaelbull.result.runCatching import com.google.android.material.dialog.MaterialAlertDialogBuilder import dev.msfjarvis.aps.R import dev.msfjarvis.aps.databinding.FragmentPwgenBinding +import dev.msfjarvis.aps.passgen.random.MaxIterationsExceededException +import dev.msfjarvis.aps.passgen.random.NoCharactersIncludedException +import dev.msfjarvis.aps.passgen.random.PasswordGenerator +import dev.msfjarvis.aps.passgen.random.PasswordLengthTooShortException +import dev.msfjarvis.aps.passgen.random.PasswordOption import dev.msfjarvis.aps.ui.crypto.PasswordCreationActivity -import dev.msfjarvis.aps.util.pwgen.PasswordGenerator -import dev.msfjarvis.aps.util.pwgen.PasswordGenerator.generate -import dev.msfjarvis.aps.util.pwgen.PasswordGenerator.setPrefs -import dev.msfjarvis.aps.util.pwgen.PasswordOption import dev.msfjarvis.aps.util.settings.PreferenceKeys import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.launchIn @@ -96,10 +98,23 @@ class PasswordGeneratorDialogFragment : DialogFragment() { } private fun generate(passwordField: AppCompatTextView) { - setPreferences() + val passwordOptions = getSelectedOptions() + val passwordLength = getLength() + setPrefs(requireContext(), passwordOptions, passwordLength) passwordField.text = - runCatching { generate(requireContext().applicationContext) }.getOrElse { e -> - Toast.makeText(requireActivity(), e.message, Toast.LENGTH_SHORT).show() + runCatching { PasswordGenerator.generate(passwordOptions, passwordLength) }.getOrElse { + exception -> + val errorText = + when (exception) { + is MaxIterationsExceededException -> + requireContext().getString(R.string.pwgen_max_iterations_exceeded) + is NoCharactersIncludedException -> + requireContext().getString(R.string.pwgen_no_chars_error) + is PasswordLengthTooShortException -> + requireContext().getString(R.string.pwgen_length_too_short_error) + else -> requireContext().getString(R.string.pwgen_some_error_occurred) + } + Toast.makeText(requireActivity(), errorText, Toast.LENGTH_SHORT).show() "" } } @@ -108,18 +123,34 @@ class PasswordGeneratorDialogFragment : DialogFragment() { return requireDialog().findViewById(id).isChecked } - private fun setPreferences() { - val preferences = - listOfNotNull( - PasswordOption.NoDigits.takeIf { !isChecked(R.id.numerals) }, - PasswordOption.AtLeastOneSymbol.takeIf { isChecked(R.id.symbols) }, - PasswordOption.NoUppercaseLetters.takeIf { !isChecked(R.id.uppercase) }, - PasswordOption.NoAmbiguousCharacters.takeIf { !isChecked(R.id.ambiguous) }, - PasswordOption.FullyRandom.takeIf { !isChecked(R.id.pronounceable) }, - PasswordOption.NoLowercaseLetters.takeIf { !isChecked(R.id.lowercase) } - ) + private fun getSelectedOptions(): List { + return listOfNotNull( + PasswordOption.NoDigits.takeIf { !isChecked(R.id.numerals) }, + PasswordOption.AtLeastOneSymbol.takeIf { isChecked(R.id.symbols) }, + PasswordOption.NoUppercaseLetters.takeIf { !isChecked(R.id.uppercase) }, + PasswordOption.NoAmbiguousCharacters.takeIf { !isChecked(R.id.ambiguous) }, + PasswordOption.FullyRandom.takeIf { !isChecked(R.id.pronounceable) }, + PasswordOption.NoLowercaseLetters.takeIf { !isChecked(R.id.lowercase) } + ) + } + + private fun getLength(): Int { val lengthText = requireDialog().findViewById(R.id.lengthNumber).text.toString() - val length = lengthText.toIntOrNull()?.takeIf { it >= 0 } ?: PasswordGenerator.DEFAULT_LENGTH - setPrefs(requireActivity().applicationContext, preferences, length) + return lengthText.toIntOrNull()?.takeIf { it >= 0 } ?: PasswordGenerator.DEFAULT_LENGTH + } + + /** + * Enables the [PasswordOption]s in [options] and sets [targetLength] as the length for generated + * passwords. + */ + private fun setPrefs(ctx: Context, options: List, targetLength: Int): Boolean { + ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit { + for (possibleOption in PasswordOption.values()) putBoolean( + possibleOption.key, + possibleOption in options + ) + putInt("length", targetLength) + } + return true } } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/PasswordGenerator.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/PasswordGenerator.kt deleted file mode 100644 index bd21ea0a..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/PasswordGenerator.kt +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.pwgen - -import android.content.Context -import androidx.core.content.edit -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.util.extensions.clearFlag -import dev.msfjarvis.aps.util.extensions.hasFlag -import dev.msfjarvis.aps.util.settings.PreferenceKeys - -enum class PasswordOption(val key: String) { - NoDigits("0"), - NoUppercaseLetters("A"), - NoAmbiguousCharacters("B"), - FullyRandom("s"), - AtLeastOneSymbol("y"), - NoLowercaseLetters("L") -} - -object PasswordGenerator { - - const val DEFAULT_LENGTH = 16 - - const val DIGITS = 0x0001 - const val UPPERS = 0x0002 - const val SYMBOLS = 0x0004 - const val NO_AMBIGUOUS = 0x0008 - const val LOWERS = 0x0020 - - const val DIGITS_STR = "0123456789" - const val UPPERS_STR = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - const val LOWERS_STR = "abcdefghijklmnopqrstuvwxyz" - const val SYMBOLS_STR = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" - const val AMBIGUOUS_STR = "B8G6I1l0OQDS5Z2" - - /** - * Enables the [PasswordOption] s in [options] and sets [targetLength] as the length for generated - * passwords. - */ - fun setPrefs(ctx: Context, options: List, targetLength: Int): Boolean { - ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit { - for (possibleOption in PasswordOption.values()) putBoolean( - possibleOption.key, - possibleOption in options - ) - putInt("length", targetLength) - } - return true - } - - fun isValidPassword(password: String, pwFlags: Int): Boolean { - if (pwFlags hasFlag DIGITS && password.none { it in DIGITS_STR }) return false - if (pwFlags hasFlag UPPERS && password.none { it in UPPERS_STR }) return false - if (pwFlags hasFlag LOWERS && password.none { it in LOWERS_STR }) return false - if (pwFlags hasFlag SYMBOLS && password.none { it in SYMBOLS_STR }) return false - if (pwFlags hasFlag NO_AMBIGUOUS && password.any { it in AMBIGUOUS_STR }) return false - return true - } - - /** Generates a password using the preferences set by [setPrefs]. */ - @Throws(PasswordGeneratorException::class) - fun generate(ctx: Context): String { - val prefs = ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE) - var numCharacterCategories = 0 - - var phonemes = true - var pwgenFlags = DIGITS or UPPERS or LOWERS - - for (option in PasswordOption.values()) { - if (prefs.getBoolean(option.key, false)) { - when (option) { - PasswordOption.NoDigits -> pwgenFlags = pwgenFlags.clearFlag(DIGITS) - PasswordOption.NoUppercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(UPPERS) - PasswordOption.NoLowercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(LOWERS) - PasswordOption.NoAmbiguousCharacters -> pwgenFlags = pwgenFlags or NO_AMBIGUOUS - PasswordOption.FullyRandom -> phonemes = false - PasswordOption.AtLeastOneSymbol -> { - numCharacterCategories++ - pwgenFlags = pwgenFlags or SYMBOLS - } - } - } else { - // The No* options are false, so the respective character category will be included. - when (option) { - PasswordOption.NoDigits, - PasswordOption.NoUppercaseLetters, - PasswordOption.NoLowercaseLetters -> { - numCharacterCategories++ - } - PasswordOption.NoAmbiguousCharacters, - PasswordOption.FullyRandom, - // Since AtLeastOneSymbol is not negated, it is counted in the if branch. - PasswordOption.AtLeastOneSymbol -> {} - } - } - } - - val length = prefs.getInt(PreferenceKeys.LENGTH, DEFAULT_LENGTH) - if (pwgenFlags.clearFlag(NO_AMBIGUOUS) == 0) { - throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_no_chars_error)) - } - if (length < numCharacterCategories) { - throw PasswordGeneratorException( - ctx.resources.getString(R.string.pwgen_length_too_short_error) - ) - } - if (!(pwgenFlags hasFlag UPPERS) && !(pwgenFlags hasFlag LOWERS)) { - phonemes = false - pwgenFlags = pwgenFlags.clearFlag(NO_AMBIGUOUS) - } - // Experiments show that phonemes may require more than 1000 iterations to generate a valid - // password if the length is not at least 6. - if (length < 6) { - phonemes = false - } - - var password: String? - var iterations = 0 - do { - if (iterations++ > 1000) - throw PasswordGeneratorException( - ctx.resources.getString(R.string.pwgen_max_iterations_exceeded) - ) - password = - if (phonemes) { - RandomPhonemesGenerator.generate(length, pwgenFlags) - } else { - RandomPasswordGenerator.generate(length, pwgenFlags) - } - } while (password == null) - return password - } - - class PasswordGeneratorException(string: String) : Exception(string) -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomNumberGenerator.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomNumberGenerator.kt deleted file mode 100644 index 8ef490cc..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomNumberGenerator.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.pwgen - -import java.security.SecureRandom - -private val secureRandom = SecureRandom() - -/** Returns a number between 0 (inclusive) and [exclusiveBound](exclusive). */ -fun secureRandomNumber(exclusiveBound: Int) = secureRandom.nextInt(exclusiveBound) - -/** Returns `true` and `false` with probablity 50% each. */ -fun secureRandomBoolean() = secureRandom.nextBoolean() - -/** - * Returns `true` with probability [percentTrue]% and `false` with probability `(100 - [percentTrue] - * )`%. - */ -fun secureRandomBiasedBoolean(percentTrue: Int): Boolean { - require(1 <= percentTrue) { "Probability for returning `true` must be at least 1%" } - require(percentTrue <= 99) { "Probability for returning `true` must be at most 99%" } - return secureRandomNumber(100) < percentTrue -} - -fun Array.secureRandomElement() = this[secureRandomNumber(size)] - -fun List.secureRandomElement() = this[secureRandomNumber(size)] - -fun String.secureRandomCharacter() = this[secureRandomNumber(length)] diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPasswordGenerator.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPasswordGenerator.kt deleted file mode 100644 index c1a8aeb1..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPasswordGenerator.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.pwgen - -import dev.msfjarvis.aps.util.extensions.hasFlag - -object RandomPasswordGenerator { - - /** - * Generates a random password of length [targetLength], taking the following flags in [pwFlags] - * into account, or fails to do so and returns null: - * - * - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not set, - * the password will not contain any digits. - * - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase letter; - * if not set, the password will not contain any uppercase letters. - * - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase letter; - * if not set, the password will not contain any lowercase letters. - * - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not - * set, the password will not contain any symbols. - * - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous - * characters. - */ - fun generate(targetLength: Int, pwFlags: Int): String? { - val bank = - listOfNotNull( - PasswordGenerator.DIGITS_STR.takeIf { pwFlags hasFlag PasswordGenerator.DIGITS }, - PasswordGenerator.UPPERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.UPPERS }, - PasswordGenerator.LOWERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.LOWERS }, - PasswordGenerator.SYMBOLS_STR.takeIf { pwFlags hasFlag PasswordGenerator.SYMBOLS }, - ) - .joinToString("") - - var password = "" - while (password.length < targetLength) { - val candidate = bank.secureRandomCharacter() - if (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && - candidate in PasswordGenerator.AMBIGUOUS_STR - ) { - continue - } - password += candidate - } - return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPhonemesGenerator.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPhonemesGenerator.kt deleted file mode 100644 index 5a5f5f21..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPhonemesGenerator.kt +++ /dev/null @@ -1,180 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.util.pwgen - -import dev.msfjarvis.aps.util.extensions.hasFlag -import java.util.Locale - -object RandomPhonemesGenerator { - - private const val CONSONANT = 0x0001 - private const val VOWEL = 0x0002 - private const val DIPHTHONG = 0x0004 - private const val NOT_FIRST = 0x0008 - - private val elements = - arrayOf( - Element("a", VOWEL), - Element("ae", VOWEL or DIPHTHONG), - Element("ah", VOWEL or DIPHTHONG), - Element("ai", VOWEL or DIPHTHONG), - Element("b", CONSONANT), - Element("c", CONSONANT), - Element("ch", CONSONANT or DIPHTHONG), - Element("d", CONSONANT), - Element("e", VOWEL), - Element("ee", VOWEL or DIPHTHONG), - Element("ei", VOWEL or DIPHTHONG), - Element("f", CONSONANT), - Element("g", CONSONANT), - Element("gh", CONSONANT or DIPHTHONG or NOT_FIRST), - Element("h", CONSONANT), - Element("i", VOWEL), - Element("ie", VOWEL or DIPHTHONG), - Element("j", CONSONANT), - Element("k", CONSONANT), - Element("l", CONSONANT), - Element("m", CONSONANT), - Element("n", CONSONANT), - Element("ng", CONSONANT or DIPHTHONG or NOT_FIRST), - Element("o", VOWEL), - Element("oh", VOWEL or DIPHTHONG), - Element("oo", VOWEL or DIPHTHONG), - Element("p", CONSONANT), - Element("ph", CONSONANT or DIPHTHONG), - Element("qu", CONSONANT or DIPHTHONG), - Element("r", CONSONANT), - Element("s", CONSONANT), - Element("sh", CONSONANT or DIPHTHONG), - Element("t", CONSONANT), - Element("th", CONSONANT or DIPHTHONG), - Element("u", VOWEL), - Element("v", CONSONANT), - Element("w", CONSONANT), - Element("x", CONSONANT), - Element("y", CONSONANT), - Element("z", CONSONANT) - ) - - private class Element(str: String, val flags: Int) { - - val upperCase = str.uppercase(Locale.ROOT) - val lowerCase = str.lowercase(Locale.ROOT) - val length = str.length - val isAmbiguous = str.any { it in PasswordGenerator.AMBIGUOUS_STR } - } - - /** - * Generates a random human-readable password of length [targetLength], taking the following flags - * in [pwFlags] into account, or fails to do so and returns null: - * - * - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not set, - * the password will not contain any digits. - * - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase letter; - * if not set, the password will not contain any uppercase letters. - * - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase letter; - * if not set and [PasswordGenerator.UPPERS] is set, the password will not contain any lowercase - * characters; if both are not set, an exception is thrown. - * - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not - * set, the password will not contain any symbols. - * - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous - * characters. - */ - fun generate(targetLength: Int, pwFlags: Int): String? { - require(pwFlags hasFlag PasswordGenerator.UPPERS || pwFlags hasFlag PasswordGenerator.LOWERS) - - var password = "" - - var isStartOfPart = true - var nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT - var previousFlags = 0 - - while (password.length < targetLength) { - // First part: Add a single letter or pronounceable pair of letters in varying case. - - val candidate = elements.secureRandomElement() - - // Reroll if the candidate does not fulfill the current requirements. - if (!candidate.flags.hasFlag(nextBasicType) || - (isStartOfPart && candidate.flags hasFlag NOT_FIRST) || - // Don't let a diphthong that starts with a vowel follow a vowel. - (previousFlags hasFlag VOWEL && - candidate.flags hasFlag VOWEL && - candidate.flags hasFlag DIPHTHONG) || - // Don't add multi-character candidates if we would go over the targetLength. - (password.length + candidate.length > targetLength) || - (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && candidate.isAmbiguous) - ) { - continue - } - - // At this point the candidate could be appended to the password, but we still have - // to determine the case. If no upper case characters are required, we don't add - // any. - val useUpperIfBothCasesAllowed = - (isStartOfPart || candidate.flags hasFlag CONSONANT) && secureRandomBiasedBoolean(20) - password += - if (pwFlags hasFlag PasswordGenerator.UPPERS && - (!(pwFlags hasFlag PasswordGenerator.LOWERS) || useUpperIfBothCasesAllowed) - ) { - candidate.upperCase - } else { - candidate.lowerCase - } - - // We ensured above that we will not go above the target length. - check(password.length <= targetLength) - if (password.length == targetLength) break - - // Second part: Add digits and symbols with a certain probability (if requested) if - // they would not directly follow the first character in a pronounceable part. - - if (!isStartOfPart && - pwFlags hasFlag PasswordGenerator.DIGITS && - secureRandomBiasedBoolean(30) - ) { - var randomDigit: Char - do { - randomDigit = secureRandomNumber(10).toString(10).first() - } while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && - randomDigit in PasswordGenerator.AMBIGUOUS_STR) - - password += randomDigit - // Begin a new pronounceable part after every digit. - isStartOfPart = true - nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT - previousFlags = 0 - continue - } - - if (!isStartOfPart && - pwFlags hasFlag PasswordGenerator.SYMBOLS && - secureRandomBiasedBoolean(20) - ) { - var randomSymbol: Char - do { - randomSymbol = PasswordGenerator.SYMBOLS_STR.secureRandomCharacter() - } while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && - randomSymbol in PasswordGenerator.AMBIGUOUS_STR) - password += randomSymbol - // Continue the password generation as if nothing was added. - } - - // Third part: Determine the basic type of the next character depending on the letter - // we just added. - nextBasicType = - when { - candidate.flags.hasFlag(CONSONANT) -> VOWEL - previousFlags.hasFlag(VOWEL) || - candidate.flags.hasFlag(DIPHTHONG) || - secureRandomBiasedBoolean(60) -> CONSONANT - else -> VOWEL - } - previousFlags = candidate.flags - isStartOfPart = false - } - return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) } - } -} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7b476587..c11e6f25 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -391,5 +391,6 @@ Successfully imported PGP key The key ID of the imported key is given below, please review it for correctness:\n%1$s PGP settings + Some error occurred diff --git a/build-logic/kotlin-plugins/src/main/kotlin/com.github.android-password-store.binary-compatibility.gradle.kts b/build-logic/kotlin-plugins/src/main/kotlin/com.github.android-password-store.binary-compatibility.gradle.kts index 4e0ceaa3..2aec2844 100644 --- a/build-logic/kotlin-plugins/src/main/kotlin/com.github.android-password-store.binary-compatibility.gradle.kts +++ b/build-logic/kotlin-plugins/src/main/kotlin/com.github.android-password-store.binary-compatibility.gradle.kts @@ -15,5 +15,6 @@ apiValidation { "crypto-pgpainless", "format-common", "diceware", + "random", ) } diff --git a/passgen/random/.gitignore b/passgen/random/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/passgen/random/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/passgen/random/build.gradle.kts b/passgen/random/build.gradle.kts new file mode 100644 index 00000000..f91458dd --- /dev/null +++ b/passgen/random/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + kotlin("jvm") + id("com.github.android-password-store.kotlin-library") +} diff --git a/passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/PasswordGenerator.kt b/passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/PasswordGenerator.kt new file mode 100644 index 00000000..fcd28311 --- /dev/null +++ b/passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/PasswordGenerator.kt @@ -0,0 +1,100 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.passgen.random + +import dev.msfjarvis.aps.passgen.random.util.clearFlag +import dev.msfjarvis.aps.passgen.random.util.hasFlag + +public object PasswordGenerator { + + public const val DEFAULT_LENGTH: Int = 16 + + internal const val DIGITS = 0x0001 + internal const val UPPERS = 0x0002 + internal const val SYMBOLS = 0x0004 + internal const val NO_AMBIGUOUS = 0x0008 + internal const val LOWERS = 0x0020 + + internal const val DIGITS_STR = "0123456789" + internal const val UPPERS_STR = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + internal const val LOWERS_STR = "abcdefghijklmnopqrstuvwxyz" + internal const val SYMBOLS_STR = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" + internal const val AMBIGUOUS_STR = "B8G6I1l0OQDS5Z2" + + internal fun isValidPassword(password: String, pwFlags: Int): Boolean { + if (pwFlags hasFlag DIGITS && password.none { it in DIGITS_STR }) return false + if (pwFlags hasFlag UPPERS && password.none { it in UPPERS_STR }) return false + if (pwFlags hasFlag LOWERS && password.none { it in LOWERS_STR }) return false + if (pwFlags hasFlag SYMBOLS && password.none { it in SYMBOLS_STR }) return false + if (pwFlags hasFlag NO_AMBIGUOUS && password.any { it in AMBIGUOUS_STR }) return false + return true + } + + /** Generates a password using the given [passwordOptions] and [length]. */ + @Throws(PasswordGeneratorException::class) + public fun generate(passwordOptions: List, length: Int = DEFAULT_LENGTH): String { + var numCharacterCategories = 0 + var phonemes = true + var pwgenFlags = DIGITS or UPPERS or LOWERS + + for (option in PasswordOption.values()) { + if (option in passwordOptions) { + when (option) { + PasswordOption.NoDigits -> pwgenFlags = pwgenFlags.clearFlag(DIGITS) + PasswordOption.NoUppercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(UPPERS) + PasswordOption.NoLowercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(LOWERS) + PasswordOption.NoAmbiguousCharacters -> pwgenFlags = pwgenFlags or NO_AMBIGUOUS + PasswordOption.FullyRandom -> phonemes = false + PasswordOption.AtLeastOneSymbol -> { + numCharacterCategories++ + pwgenFlags = pwgenFlags or SYMBOLS + } + } + } else { + // The No* options are false, so the respective character category will be included. + when (option) { + PasswordOption.NoDigits, + PasswordOption.NoUppercaseLetters, + PasswordOption.NoLowercaseLetters -> { + numCharacterCategories++ + } + PasswordOption.NoAmbiguousCharacters, + PasswordOption.FullyRandom, + // Since AtLeastOneSymbol is not negated, it is counted in the if branch. + PasswordOption.AtLeastOneSymbol -> {} + } + } + } + + if (pwgenFlags.clearFlag(NO_AMBIGUOUS) == 0) { + throw NoCharactersIncludedException() + } + if (length < numCharacterCategories) { + throw PasswordLengthTooShortException() + } + if (!(pwgenFlags hasFlag UPPERS) && !(pwgenFlags hasFlag LOWERS)) { + phonemes = false + pwgenFlags = pwgenFlags.clearFlag(NO_AMBIGUOUS) + } + // Experiments show that phonemes may require more than 1000 iterations to generate a valid + // password if the length is not at least 6. + if (length < 6) { + phonemes = false + } + + var password: String? + var iterations = 0 + do { + if (iterations++ > 1000) throw MaxIterationsExceededException() + password = + if (phonemes) { + RandomPhonemesGenerator.generate(length, pwgenFlags) + } else { + RandomPasswordGenerator.generate(length, pwgenFlags) + } + } while (password == null) + return password + } +} diff --git a/passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/PasswordGeneratorException.kt b/passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/PasswordGeneratorException.kt new file mode 100644 index 00000000..b7d70c39 --- /dev/null +++ b/passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/PasswordGeneratorException.kt @@ -0,0 +1,10 @@ +package dev.msfjarvis.aps.passgen.random + +public sealed class PasswordGeneratorException(message: String? = null, cause: Throwable? = null) : + Throwable(message, cause) + +public class MaxIterationsExceededException : PasswordGeneratorException() + +public class NoCharactersIncludedException : PasswordGeneratorException() + +public class PasswordLengthTooShortException : PasswordGeneratorException() diff --git a/passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/PasswordOption.kt b/passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/PasswordOption.kt new file mode 100644 index 00000000..70d0da22 --- /dev/null +++ b/passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/PasswordOption.kt @@ -0,0 +1,10 @@ +package dev.msfjarvis.aps.passgen.random + +public enum class PasswordOption(public val key: String) { + NoDigits("0"), + NoUppercaseLetters("A"), + NoAmbiguousCharacters("B"), + FullyRandom("s"), + AtLeastOneSymbol("y"), + NoLowercaseLetters("L") +} diff --git a/passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/RandomNumberGenerator.kt b/passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/RandomNumberGenerator.kt new file mode 100644 index 00000000..3bb34325 --- /dev/null +++ b/passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/RandomNumberGenerator.kt @@ -0,0 +1,31 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.passgen.random + +import java.security.SecureRandom + +private val secureRandom = SecureRandom() + +/** Returns a number between 0 (inclusive) and [exclusiveBound](exclusive). */ +internal fun secureRandomNumber(exclusiveBound: Int) = secureRandom.nextInt(exclusiveBound) + +/** Returns `true` and `false` with probablity 50% each. */ +internal fun secureRandomBoolean() = secureRandom.nextBoolean() + +/** + * Returns `true` with probability [percentTrue]% and `false` with probability `(100 - [percentTrue] + * )`%. + */ +internal fun secureRandomBiasedBoolean(percentTrue: Int): Boolean { + require(1 <= percentTrue) { "Probability for returning `true` must be at least 1%" } + require(percentTrue <= 99) { "Probability for returning `true` must be at most 99%" } + return secureRandomNumber(100) < percentTrue +} + +internal fun Array.secureRandomElement() = this[secureRandomNumber(size)] + +internal fun List.secureRandomElement() = this[secureRandomNumber(size)] + +internal fun String.secureRandomCharacter() = this[secureRandomNumber(length)] diff --git a/passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/RandomPasswordGenerator.kt b/passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/RandomPasswordGenerator.kt new file mode 100644 index 00000000..32d3ff49 --- /dev/null +++ b/passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/RandomPasswordGenerator.kt @@ -0,0 +1,48 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.passgen.random + +import dev.msfjarvis.aps.passgen.random.util.hasFlag + +internal object RandomPasswordGenerator { + + /** + * Generates a random password of length [targetLength], taking the following flags in [pwFlags] + * into account, or fails to do so and returns null: + * + * - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not set, + * the password will not contain any digits. + * - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase letter; + * if not set, the password will not contain any uppercase letters. + * - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase letter; + * if not set, the password will not contain any lowercase letters. + * - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not + * set, the password will not contain any symbols. + * - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous + * characters. + */ + fun generate(targetLength: Int, pwFlags: Int): String? { + val bank = + listOfNotNull( + PasswordGenerator.DIGITS_STR.takeIf { pwFlags hasFlag PasswordGenerator.DIGITS }, + PasswordGenerator.UPPERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.UPPERS }, + PasswordGenerator.LOWERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.LOWERS }, + PasswordGenerator.SYMBOLS_STR.takeIf { pwFlags hasFlag PasswordGenerator.SYMBOLS }, + ) + .joinToString("") + + var password = "" + while (password.length < targetLength) { + val candidate = bank.secureRandomCharacter() + if (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && + candidate in PasswordGenerator.AMBIGUOUS_STR + ) { + continue + } + password += candidate + } + return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) } + } +} diff --git a/passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/RandomPhonemesGenerator.kt b/passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/RandomPhonemesGenerator.kt new file mode 100644 index 00000000..b9df4324 --- /dev/null +++ b/passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/RandomPhonemesGenerator.kt @@ -0,0 +1,180 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.passgen.random + +import dev.msfjarvis.aps.passgen.random.util.hasFlag +import java.util.Locale + +internal object RandomPhonemesGenerator { + + private const val CONSONANT = 0x0001 + private const val VOWEL = 0x0002 + private const val DIPHTHONG = 0x0004 + private const val NOT_FIRST = 0x0008 + + private val elements = + arrayOf( + Element("a", VOWEL), + Element("ae", VOWEL or DIPHTHONG), + Element("ah", VOWEL or DIPHTHONG), + Element("ai", VOWEL or DIPHTHONG), + Element("b", CONSONANT), + Element("c", CONSONANT), + Element("ch", CONSONANT or DIPHTHONG), + Element("d", CONSONANT), + Element("e", VOWEL), + Element("ee", VOWEL or DIPHTHONG), + Element("ei", VOWEL or DIPHTHONG), + Element("f", CONSONANT), + Element("g", CONSONANT), + Element("gh", CONSONANT or DIPHTHONG or NOT_FIRST), + Element("h", CONSONANT), + Element("i", VOWEL), + Element("ie", VOWEL or DIPHTHONG), + Element("j", CONSONANT), + Element("k", CONSONANT), + Element("l", CONSONANT), + Element("m", CONSONANT), + Element("n", CONSONANT), + Element("ng", CONSONANT or DIPHTHONG or NOT_FIRST), + Element("o", VOWEL), + Element("oh", VOWEL or DIPHTHONG), + Element("oo", VOWEL or DIPHTHONG), + Element("p", CONSONANT), + Element("ph", CONSONANT or DIPHTHONG), + Element("qu", CONSONANT or DIPHTHONG), + Element("r", CONSONANT), + Element("s", CONSONANT), + Element("sh", CONSONANT or DIPHTHONG), + Element("t", CONSONANT), + Element("th", CONSONANT or DIPHTHONG), + Element("u", VOWEL), + Element("v", CONSONANT), + Element("w", CONSONANT), + Element("x", CONSONANT), + Element("y", CONSONANT), + Element("z", CONSONANT) + ) + + private class Element(str: String, val flags: Int) { + + val upperCase = str.uppercase(Locale.ROOT) + val lowerCase = str.lowercase(Locale.ROOT) + val length = str.length + val isAmbiguous = str.any { it in PasswordGenerator.AMBIGUOUS_STR } + } + + /** + * Generates a random human-readable password of length [targetLength], taking the following flags + * in [pwFlags] into account, or fails to do so and returns null: + * + * - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not set, + * the password will not contain any digits. + * - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase letter; + * if not set, the password will not contain any uppercase letters. + * - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase letter; + * if not set and [PasswordGenerator.UPPERS] is set, the password will not contain any lowercase + * characters; if both are not set, an exception is thrown. + * - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not + * set, the password will not contain any symbols. + * - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous + * characters. + */ + fun generate(targetLength: Int, pwFlags: Int): String? { + require(pwFlags hasFlag PasswordGenerator.UPPERS || pwFlags hasFlag PasswordGenerator.LOWERS) + + var password = "" + + var isStartOfPart = true + var nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT + var previousFlags = 0 + + while (password.length < targetLength) { + // First part: Add a single letter or pronounceable pair of letters in varying case. + + val candidate = elements.secureRandomElement() + + // Reroll if the candidate does not fulfill the current requirements. + if (!candidate.flags.hasFlag(nextBasicType) || + (isStartOfPart && candidate.flags hasFlag NOT_FIRST) || + // Don't let a diphthong that starts with a vowel follow a vowel. + (previousFlags hasFlag VOWEL && + candidate.flags hasFlag VOWEL && + candidate.flags hasFlag DIPHTHONG) || + // Don't add multi-character candidates if we would go over the targetLength. + (password.length + candidate.length > targetLength) || + (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && candidate.isAmbiguous) + ) { + continue + } + + // At this point the candidate could be appended to the password, but we still have + // to determine the case. If no upper case characters are required, we don't add + // any. + val useUpperIfBothCasesAllowed = + (isStartOfPart || candidate.flags hasFlag CONSONANT) && secureRandomBiasedBoolean(20) + password += + if (pwFlags hasFlag PasswordGenerator.UPPERS && + (!(pwFlags hasFlag PasswordGenerator.LOWERS) || useUpperIfBothCasesAllowed) + ) { + candidate.upperCase + } else { + candidate.lowerCase + } + + // We ensured above that we will not go above the target length. + check(password.length <= targetLength) + if (password.length == targetLength) break + + // Second part: Add digits and symbols with a certain probability (if requested) if + // they would not directly follow the first character in a pronounceable part. + + if (!isStartOfPart && + pwFlags hasFlag PasswordGenerator.DIGITS && + secureRandomBiasedBoolean(30) + ) { + var randomDigit: Char + do { + randomDigit = secureRandomNumber(10).toString(10).first() + } while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && + randomDigit in PasswordGenerator.AMBIGUOUS_STR) + + password += randomDigit + // Begin a new pronounceable part after every digit. + isStartOfPart = true + nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT + previousFlags = 0 + continue + } + + if (!isStartOfPart && + pwFlags hasFlag PasswordGenerator.SYMBOLS && + secureRandomBiasedBoolean(20) + ) { + var randomSymbol: Char + do { + randomSymbol = PasswordGenerator.SYMBOLS_STR.secureRandomCharacter() + } while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && + randomSymbol in PasswordGenerator.AMBIGUOUS_STR) + password += randomSymbol + // Continue the password generation as if nothing was added. + } + + // Third part: Determine the basic type of the next character depending on the letter + // we just added. + nextBasicType = + when { + candidate.flags.hasFlag(CONSONANT) -> VOWEL + previousFlags.hasFlag(VOWEL) || + candidate.flags.hasFlag(DIPHTHONG) || + secureRandomBiasedBoolean(60) -> CONSONANT + else -> VOWEL + } + previousFlags = candidate.flags + isStartOfPart = false + } + return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) } + } +} diff --git a/passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/util/Extensions.kt b/passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/util/Extensions.kt new file mode 100644 index 00000000..3b38e22b --- /dev/null +++ b/passgen/random/src/main/kotlin/dev/msfjarvis/aps/passgen/random/util/Extensions.kt @@ -0,0 +1,11 @@ +package dev.msfjarvis.aps.passgen.random.util + +/** Clears the given [flag] from the value of this [Int] */ +internal infix fun Int.clearFlag(flag: Int): Int { + return this and flag.inv() +} + +/** Checks if this [Int] contains the given [flag] */ +internal infix fun Int.hasFlag(flag: Int): Boolean { + return this and flag == flag +} diff --git a/settings.gradle.kts b/settings.gradle.kts index bc0c5ab3..3b2f4d2a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -53,3 +53,5 @@ include("format-common") include("openpgp-ktx") include("passgen:diceware") + +include(":passgen:random") -- cgit v1.2.3