diff options
71 files changed, 1493 insertions, 584 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index dd85ffbd..2f34dfa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Fixed + +- Folder names that were very long did not look right +- Error message for wrong SSH/HTTPS password now looks cleaner + +### Added + +- TOTP support is reintroduced by popular demand. HOTP continues to be unsupported and heavily discouraged. +- Initial support for detecting and filling OTP fields with Autofill +- Importing TOTP secrets using QR codes + ## [1.9.2] - 2020-06-30 ### Fixed @@ -1,6 +1,5 @@ # Password Store -[![Android Arsenal](https://img.shields.io/badge/Android%20Arsenal-Android--Password--Store-blue.svg?style=flat)](https://android-arsenal.com/details/1/1208) [![GitHub workflow](https://github.com/android-password-store/Android-Password-Store/workflows/Deploy%20snapshot%20builds/badge.svg)](https://github.com/android-password-store/Android-Password-Store/actions) [![Backers on Open Collective](https://opencollective.com/Android-Password-Store/backers/badge.svg)](#backers) [![Sponsors on Open Collective](https://opencollective.com/Android-Password-Store/sponsors/badge.svg)](#sponsors) [![Join the chat at https://gitter.im/android-password-store/public](https://badges.gitter.im/android-password-store/public.svg)](https://gitter.im/android-password-store/public?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) @@ -9,12 +8,24 @@ This application tries to be 100% compatible with [pass](http://www.passwordstor You can install the application from: -<!--* [F-Droid](https://f-droid.org/repository/browse/?fdid=com.zeapo.pwdstore)--> +* [F-Droid](https://f-droid.org/packages/dev.msfjarvis.aps/) * [Play Store](https://play.google.com/store/apps/details?id=dev.msfjarvis.aps) * [Snapshot builds](https://dl.msfjarvis.dev/APS/) Pull requests are more than welcome (see [TODO](https://github.com/android-password-store/Android-Password-Store/projects/1#column-228844)). +<a href="https://play.google.com/store/apps/details?id=dev.msfjarvis.aps"> + <img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" + alt="Get it on Google Play" + height="80" /> +</a> +<a href="https://f-droid.org/packages/dev.msfjarvis.aps"> + <img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" + alt="Get it on F-Droid" + height="80" /> +</a> + + ## Features * Clone an existing pass repository (ssh-key and user/pass support) @@ -59,3 +70,5 @@ Support this project by becoming a sponsor. Your logo will show up here with a l [![Applicative GmbH](https://opencollective.com/Android-Password-Store/sponsor/1/avatar.svg)](https://opencollective.com/Android-Password-Store/sponsor/1/website) [![ScrapingBee](https://opencollective.com/Android-Password-Store/sponsor/2/avatar.svg)](https://opencollective.com/Android-Password-Store/sponsor/2/website) [![Become a Sponsor](https://opencollective.com/Android-Password-Store/sponsor/3/avatar.svg)](https://opencollective.com/Android-Password-Store/sponsor/3/website) + +<sub>Google Play and the Google Play logo are trademarks of Google LLC.</sub> diff --git a/app/build.gradle b/app/build.gradle index 25040ca4..4489c0ba 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -25,8 +25,8 @@ android { defaultConfig { applicationId 'dev.msfjarvis.aps' - versionCode 10920 - versionName '1.9.2' + versionCode 10921 + versionName '1.10.0-SNAPSHOT' } lintOptions { @@ -71,8 +71,9 @@ android { } dependencies { - implementation deps.androidx.annotation implementation deps.androidx.activity_ktx + implementation deps.androidx.annotation + implementation deps.androidx.autofill implementation deps.androidx.appcompat implementation deps.androidx.biometric implementation deps.androidx.constraint_layout @@ -92,6 +93,10 @@ dependencies { implementation deps.kotlin.coroutines.android implementation deps.kotlin.coroutines.core + implementation deps.first_party.openpgp_ktx + implementation deps.first_party.zxing_android_embedded + + implementation deps.third_party.commons_codec implementation deps.third_party.fastscroll implementation(deps.third_party.jgit) { exclude group: 'org.apache.httpcomponents', module: 'httpclient' @@ -100,7 +105,6 @@ dependencies { implementation deps.third_party.sshj implementation deps.third_party.bouncycastle implementation deps.third_party.plumber - implementation deps.third_party.openpgp_ktx implementation deps.third_party.ssh_auth implementation deps.third_party.timber implementation deps.third_party.timberkt diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 232f70e7..8e53f558 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -31,3 +31,8 @@ # Tink (for security-crypto) # I'm most certainly not a fan of this catch-all rule -keep class com.google.crypto.tink.proto.** { *; } + +# WhatTheStack +-keep class com.haroldadmin.whatthestack.WhatTheStackInitializer { + <init>(); +} diff --git a/app/src/androidTest/java/com/zeapo/pwdstore/utils/UriTotpFinderTest.kt b/app/src/androidTest/java/com/zeapo/pwdstore/utils/UriTotpFinderTest.kt new file mode 100644 index 00000000..3397ed0d --- /dev/null +++ b/app/src/androidTest/java/com/zeapo/pwdstore/utils/UriTotpFinderTest.kt @@ -0,0 +1,39 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.zeapo.pwdstore.utils + +import org.junit.Test +import kotlin.test.assertEquals + +class UriTotpFinderTest { + + private val totpFinder = UriTotpFinder() + + @Test + fun findSecret() { + assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(TOTP_URI)) + assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret("name\npassword\ntotp: HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ")) + } + + @Test + fun findDigits() { + assertEquals("12", totpFinder.findDigits(TOTP_URI)) + } + + @Test + fun findPeriod() { + assertEquals(25, totpFinder.findPeriod(TOTP_URI)) + } + + @Test + fun findAlgorithm() { + assertEquals("SHA256", totpFinder.findAlgorithm(TOTP_URI)) + } + + companion object { + const val TOTP_URI = "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA256&digits=12&period=25" + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b0c3193d..2098abc9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -46,6 +46,14 @@ </activity> <activity + android:name="com.journeyapps.barcodescanner.CaptureActivity" + android:clearTaskOnLaunch="true" + android:stateNotNeeded="true" + android:theme="@style/zxing_CaptureTheme" + android:windowSoftInputMode="stateAlwaysHidden" + tools:node="replace" /> + + <activity android:name=".git.GitOperationActivity" android:theme="@style/NoBackgroundTheme" /> diff --git a/app/src/main/assets/publicsuffixes b/app/src/main/assets/publicsuffixes Binary files differindex 028c098e..5e420823 100644 --- a/app/src/main/assets/publicsuffixes +++ b/app/src/main/assets/publicsuffixes diff --git a/app/src/main/java/com/zeapo/pwdstore/Application.kt b/app/src/main/java/com/zeapo/pwdstore/Application.kt index b0c4eec0..3ccf37fe 100644 --- a/app/src/main/java/com/zeapo/pwdstore/Application.kt +++ b/app/src/main/java/com/zeapo/pwdstore/Application.kt @@ -13,8 +13,8 @@ import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES import androidx.preference.PreferenceManager import com.github.ajalt.timberkt.Timber.DebugTree import com.github.ajalt.timberkt.Timber.plant -import org.bouncycastle.jce.provider.BouncyCastleProvider -import java.security.Security +import com.zeapo.pwdstore.git.config.setUpBouncyCastleForSshj +import com.zeapo.pwdstore.utils.PreferenceKeys @Suppress("Unused") class Application : android.app.Application(), SharedPreferences.OnSharedPreferenceChangeListener { @@ -24,12 +24,13 @@ class Application : android.app.Application(), SharedPreferences.OnSharedPrefere override fun onCreate() { super.onCreate() prefs = PreferenceManager.getDefaultSharedPreferences(this) - if (BuildConfig.ENABLE_DEBUG_FEATURES || prefs?.getBoolean("enable_debug_logging", false) == true) { + if (BuildConfig.ENABLE_DEBUG_FEATURES || prefs?.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false) == + true) { plant(DebugTree()) } prefs?.registerOnSharedPreferenceChangeListener(this) setNightMode() - setUpBouncyCastle() + setUpBouncyCastleForSshj() } override fun onTerminate() { @@ -38,32 +39,13 @@ class Application : android.app.Application(), SharedPreferences.OnSharedPrefere } override fun onSharedPreferenceChanged(prefs: SharedPreferences, key: String) { - if (key == "app_theme") { + if (key == PreferenceKeys.APP_THEME) { setNightMode() } } - private fun setUpBouncyCastle() { - // Replace the Android BC provider with the Java BouncyCastle provider since the former does - // not include all the required algorithms. - // TODO: Verify that we are indeed using the fast Android-native implementation whenever - // possible. - // Note: This may affect crypto operations in other parts of the application. - val bcIndex = Security.getProviders().indexOfFirst { - it.name == BouncyCastleProvider.PROVIDER_NAME - } - if (bcIndex == -1) { - // No Android BC found, install Java BC at lowest priority. - Security.addProvider(BouncyCastleProvider()) - } else { - // Replace Android BC with Java BC, inserted at the same position. - Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) - Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1) - } - } - private fun setNightMode() { - AppCompatDelegate.setDefaultNightMode(when (prefs?.getString("app_theme", getString(R.string.app_theme_def))) { + AppCompatDelegate.setDefaultNightMode(when (prefs?.getString(PreferenceKeys.APP_THEME, getString(R.string.app_theme_def))) { "light" -> MODE_NIGHT_NO "dark" -> MODE_NIGHT_YES "follow_system" -> MODE_NIGHT_FOLLOW_SYSTEM diff --git a/app/src/main/java/com/zeapo/pwdstore/ClipboardService.kt b/app/src/main/java/com/zeapo/pwdstore/ClipboardService.kt index ceb84020..ef0cc459 100644 --- a/app/src/main/java/com/zeapo/pwdstore/ClipboardService.kt +++ b/app/src/main/java/com/zeapo/pwdstore/ClipboardService.kt @@ -17,6 +17,7 @@ import androidx.core.app.NotificationCompat import androidx.core.content.getSystemService import androidx.preference.PreferenceManager import com.github.ajalt.timberkt.d +import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.clipboard import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -44,7 +45,7 @@ class ClipboardService : Service() { ACTION_START -> { val time = try { - Integer.parseInt(settings.getString("general_show_time", "45") as String) + Integer.parseInt(settings.getString(PreferenceKeys.GENERAL_SHOW_TIME, "45") as String) } catch (e: NumberFormatException) { 45 } @@ -82,7 +83,7 @@ class ClipboardService : Service() { } private fun clearClipboard() { - val deepClear = settings.getBoolean("clear_clipboard_20x", false) + val deepClear = settings.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, false) val clipboard = clipboard if (clipboard != null) { diff --git a/app/src/main/java/com/zeapo/pwdstore/LaunchActivity.kt b/app/src/main/java/com/zeapo/pwdstore/LaunchActivity.kt index b452f521..e143657e 100644 --- a/app/src/main/java/com/zeapo/pwdstore/LaunchActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/LaunchActivity.kt @@ -12,20 +12,21 @@ import androidx.core.content.edit import androidx.preference.PreferenceManager import com.zeapo.pwdstore.crypto.DecryptActivity import com.zeapo.pwdstore.utils.BiometricAuthenticator +import com.zeapo.pwdstore.utils.PreferenceKeys class LaunchActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val prefs = PreferenceManager.getDefaultSharedPreferences(this) - if (prefs.getBoolean("biometric_auth", false)) { + if (prefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH, false)) { BiometricAuthenticator.authenticate(this) { when (it) { is BiometricAuthenticator.Result.Success -> { startTargetActivity(false) } is BiometricAuthenticator.Result.HardwareUnavailableOrDisabled -> { - prefs.edit { remove("biometric_auth") } + prefs.edit { remove(PreferenceKeys.BIOMETRIC_AUTH) } startTargetActivity(false) } is BiometricAuthenticator.Result.Failure, BiometricAuthenticator.Result.Cancelled -> { diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordEntry.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordEntry.kt deleted file mode 100644 index d9168d39..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/PasswordEntry.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore - -import java.io.ByteArrayOutputStream -import java.io.UnsupportedEncodingException - -/** - * A single entry in password store. - */ -class PasswordEntry(content: String) { - - val password: String - val username: String? - var extraContent: String - private set - - @Throws(UnsupportedEncodingException::class) - constructor(os: ByteArrayOutputStream) : this(os.toString("UTF-8")) - - init { - val passContent = content.split("\n".toRegex(), 2).toTypedArray() - password = passContent[0] - extraContent = findExtraContent(passContent) - username = findUsername() - } - - fun hasExtraContent(): Boolean { - return extraContent.isNotEmpty() - } - - fun hasUsername(): Boolean { - return username != null - } - - val extraContentWithoutUsername by lazy { - var usernameFound = false - extraContent.splitToSequence("\n").filter { line -> - if (usernameFound) - return@filter true - if (USERNAME_FIELDS.any { prefix -> line.startsWith(prefix, ignoreCase = true) }) { - usernameFound = true - return@filter false - } - true - }.joinToString(separator = "\n") - } - - private fun findUsername(): String? { - extraContent.splitToSequence("\n").forEach { line -> - for (prefix in USERNAME_FIELDS) { - if (line.startsWith(prefix, ignoreCase = true)) - return line.substring(prefix.length).trimStart() - } - } - return null - } - - private fun findExtraContent(passContent: Array<String>): String { - return if (passContent.size > 1) passContent[1] else "" - } - - companion object { - val USERNAME_FIELDS = arrayOf( - "login:", - "username:", - "user:", - "account:", - "email:", - "name:", - "handle:", - "id:", - "identity:" - ) - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt index d811f341..ca53b320 100644 --- a/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt +++ b/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt @@ -31,6 +31,7 @@ import com.zeapo.pwdstore.ui.adapters.PasswordItemRecyclerAdapter import com.zeapo.pwdstore.ui.dialogs.ItemCreationBottomSheet import com.zeapo.pwdstore.utils.PasswordItem import com.zeapo.pwdstore.utils.PasswordRepository +import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.viewBinding import me.zhanghai.android.fastscroll.FastScrollerBuilder import java.io.File @@ -78,7 +79,8 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) { } else { // When authentication is set to ConnectionMode.None then the only git operation we // can run is a pull, so automatically fallback to that. - val operationId = when (ConnectionMode.fromString(settings.getString("git_remote_auth", null))) { + val operationId = when (ConnectionMode.fromString(settings.getString + (PreferenceKeys.GIT_REMOTE_AUTH, null))) { ConnectionMode.None -> BaseGitActivity.REQUEST_PULL else -> BaseGitActivity.REQUEST_SYNC } diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt index 0a768fd0..ee258b80 100644 --- a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt +++ b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt @@ -65,6 +65,7 @@ import com.zeapo.pwdstore.utils.PasswordRepository.Companion.getRepositoryDirect import com.zeapo.pwdstore.utils.PasswordRepository.Companion.initialize import com.zeapo.pwdstore.utils.PasswordRepository.Companion.isInitialized import com.zeapo.pwdstore.utils.PasswordRepository.PasswordSortOrder.Companion.getSortOrder +import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.commitChange import com.zeapo.pwdstore.utils.isInsideRepository import com.zeapo.pwdstore.utils.listFilesRecursively @@ -121,7 +122,7 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) { // If user opens app with permission granted then revokes and returns, // prevent attempt to create password list fragment var savedInstance = savedInstanceState - if (savedInstanceState != null && (!settings.getBoolean("git_external", false) || + if (savedInstanceState != null && (!settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) || ContextCompat.checkSelfPermission( activity, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED)) { @@ -179,15 +180,20 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) { } } + public override fun onStart() { + super.onStart() + refreshPasswordList() + } + public override fun onResume() { super.onResume() // do not attempt to checkLocalRepository() if no storage permission: immediate crash - if (settings.getBoolean("git_external", false)) { + if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) { hasRequiredStoragePermissions(true) } else { checkLocalRepository() } - if (settings.getBoolean("search_on_start", false) && ::searchItem.isInitialized) { + if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false) && ::searchItem.isInitialized) { if (!searchItem.isActionViewExpanded) { searchItem.expandActionView() } @@ -206,7 +212,7 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) { override fun onCreateOptionsMenu(menu: Menu?): Boolean { val menuRes = when { - ConnectionMode.fromString(settings.getString("git_remote_auth", null)) + ConnectionMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH, null)) == ConnectionMode.None -> R.menu.main_menu_no_auth PasswordRepository.isGitRepo() -> R.menu.main_menu_git else -> R.menu.main_menu_non_git @@ -256,7 +262,7 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) { return true } }) - if (settings.getBoolean("search_on_start", false)) { + if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false)) { searchItem.expandActionView() } return super.onPrepareOptionsMenu(menu) @@ -341,7 +347,7 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) { check(localDir.mkdir()) { "Failed to create directory!" } createRepository(localDir) if (File(localDir.absolutePath + "/.gpg-id").createNewFile()) { - settings.edit { putBoolean("repository_initialized", true) } + settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) } } else { throw IllegalStateException("Failed to initialize repository state.") } @@ -356,8 +362,8 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) { } private fun initializeRepositoryInfo() { - val externalRepo = settings.getBoolean("git_external", false) - val externalRepoPath = settings.getString("git_external_repo", null) + val externalRepo = settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) + val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO, null) if (externalRepo && !hasRequiredStoragePermissions()) { return } @@ -370,7 +376,7 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) { return // if not empty, just show me the passwords! } } - val keyIds = settings.getStringSet("openpgp_key_ids_set", HashSet()) + val keyIds = settings.getStringSet(PreferenceKeys.OPENPGP_KEY_IDS_SET, HashSet()) if (keyIds != null && keyIds.isEmpty()) { MaterialAlertDialogBuilder(this) .setMessage(resources.getString(R.string.key_dialog_text)) @@ -426,12 +432,12 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) { } private fun checkLocalRepository(localDir: File?) { - if (localDir != null && settings.getBoolean("repository_initialized", false)) { + if (localDir != null && settings.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)) { d { "Check, dir: ${localDir.absolutePath}" } // do not push the fragment if we already have it if (supportFragmentManager.findFragmentByTag("PasswordsList") == null || - settings.getBoolean("repo_changed", false)) { - settings.edit { putBoolean("repo_changed", false) } + settings.getBoolean(PreferenceKeys.REPO_CHANGED, false)) { + settings.edit { putBoolean(PreferenceKeys.REPO_CHANGED, false) } plist = PasswordFragment() val args = Bundle() args.putString(REQUEST_ARG_PATH, getRepositoryDirectory(applicationContext).absolutePath) @@ -530,7 +536,7 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) { .show() return false } - if (settings.getStringSet("openpgp_key_ids_set", HashSet()).isNullOrEmpty()) { + if (settings.getStringSet(PreferenceKeys.OPENPGP_KEY_IDS_SET, HashSet()).isNullOrEmpty()) { MaterialAlertDialogBuilder(this) .setTitle(resources.getString(R.string.no_key_selected_dialog_title)) .setMessage(resources.getString(R.string.no_key_selected_dialog_text)) @@ -584,7 +590,6 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) { item.file.toRelativeString(getRepositoryDirectory(this)) } )) - refreshPasswordList() } .setNegativeButton(resources.getString(R.string.dialog_no), null) .show() @@ -662,7 +667,7 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) { } } } - resetPasswordList() + refreshPasswordList() plist?.dismissActionMode() }.launch(intent) } @@ -727,24 +732,17 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) { } /** - * Resets navigation to the repository root and refreshes the password list accordingly. - * - * Use this rather than [refreshPasswordList] after major file system operations that may remove - * the current directory and thus require a full reset of the navigation stack. - */ - fun resetPasswordList() { - model.reset() - supportActionBar!!.setDisplayHomeAsUpEnabled(false) - } - - /** - * Refreshes the password list by re-executing the last navigation or search action. - * - * Use this rather than [resetPasswordList] after file system operations limited to the current - * folder since it preserves the scroll position and navigation stack. + * Refreshes the password list by re-executing the last navigation or search action, preserving + * the navigation stack and scroll position. If the current directory no longer exists, + * navigation is reset to the repository root. */ fun refreshPasswordList() { - model.forceRefresh() + if (model.currentDir.value?.isDirectory == true) { + model.forceRefresh() + } else { + model.reset() + supportActionBar!!.setDisplayHomeAsUpEnabled(false) + } } private val currentDir: File @@ -754,7 +752,7 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) { if (resultCode == Activity.RESULT_OK) { when (requestCode) { // if we get here with a RESULT_OK then it's probably OK :) - BaseGitActivity.REQUEST_CLONE -> settings.edit { putBoolean("repository_initialized", true) } + BaseGitActivity.REQUEST_CLONE -> settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) } // if went from decrypt->edit and user saved changes, we need to commitChange REQUEST_CODE_DECRYPT_AND_VERIFY -> { if (data != null && data.getBooleanExtra("needCommit", false)) { @@ -763,21 +761,19 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) { data.extras!!.getString("LONG_NAME"))) } } - refreshPasswordList() } REQUEST_CODE_ENCRYPT -> { commitChange(resources.getString(R.string.git_commit_add_text, data!!.extras!!.getString("LONG_NAME"))) - refreshPasswordList() } BaseGitActivity.REQUEST_INIT, NEW_REPO_BUTTON -> initializeRepositoryInfo() - BaseGitActivity.REQUEST_SYNC, BaseGitActivity.REQUEST_PULL -> resetPasswordList() + BaseGitActivity.REQUEST_SYNC, BaseGitActivity.REQUEST_PULL -> refreshPasswordList() HOME -> checkLocalRepository() // duplicate code CLONE_REPO_BUTTON -> { - if (settings.getBoolean("git_external", false) && - settings.getString("git_external_repo", null) != null) { - val externalRepoPath = settings.getString("git_external_repo", null) + if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) && + settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO, null) != null) { + val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO, null) val dir = externalRepoPath?.let { File(it) } if (dir != null && dir.exists() && @@ -793,6 +789,14 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) { intent.putExtra(BaseGitActivity.REQUEST_ARG_OP, BaseGitActivity.REQUEST_CLONE) startActivityForResult(intent, BaseGitActivity.REQUEST_CLONE) } + else -> { + d { "Unexpected request code: $requestCode" } + // FIXME: The sync operation returns with a requestCode of 65535 instead of the + // expected 105. It is completely unclear why, but the issue might be resolved + // by switching to ActivityResultContracts. For now, we run the post-sync code + // also when encountering an unexpected request code. + refreshPasswordList() + } } } super.onActivityResult(requestCode, resultCode, data) @@ -829,7 +833,7 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) { .setTitle(resources.getString(R.string.location_dialog_title)) .setMessage(resources.getString(R.string.location_dialog_text)) .setPositiveButton(resources.getString(R.string.location_hidden)) { _, _ -> - settings.edit { putBoolean("git_external", false) } + settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, false) } when (operation) { NEW_REPO_BUTTON -> initializeRepositoryInfo() CLONE_REPO_BUTTON -> { @@ -840,8 +844,8 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) { } } .setNegativeButton(resources.getString(R.string.location_sdcard)) { _, _ -> - settings.edit { putBoolean("git_external", true) } - val externalRepo = settings.getString("git_external_repo", null) + settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, true) } + val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO, null) if (externalRepo == null) { val intent = Intent(activity, UserPreference::class.java) intent.putExtra("operation", "git_external") diff --git a/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt b/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt index 05ccf28f..860676e4 100644 --- a/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt +++ b/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt @@ -30,6 +30,7 @@ import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure import com.zeapo.pwdstore.utils.PasswordItem import com.zeapo.pwdstore.utils.PasswordRepository +import com.zeapo.pwdstore.utils.PreferenceKeys import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview @@ -140,9 +141,9 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel get() = PasswordRepository.getRepositoryDirectory(getApplication()) private val settings = PreferenceManager.getDefaultSharedPreferences(getApplication()) private val showHiddenDirs - get() = settings.getBoolean("show_hidden_folders", false) + get() = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false) private val defaultSearchMode - get() = if (settings.getBoolean("filter_recursively", true)) { + get() = if (settings.getBoolean(PreferenceKeys.FILTER_RECURSIVELY, true)) { SearchMode.RecursivelyInSubdirectories } else { SearchMode.InCurrentDirectoryOnly diff --git a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt index fb27ba6a..03e314f6 100644 --- a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt +++ b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt @@ -52,6 +52,7 @@ import com.zeapo.pwdstore.sshkeygen.ShowSshKeyFragment import com.zeapo.pwdstore.sshkeygen.SshKeyGenActivity import com.zeapo.pwdstore.utils.BiometricAuthenticator import com.zeapo.pwdstore.utils.PasswordRepository +import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.autofillManager import com.zeapo.pwdstore.utils.getEncryptedPrefs import me.msfjarvis.openpgpktx.util.OpenPgpUtils @@ -88,16 +89,16 @@ class UserPreference : AppCompatActivity() { addPreferencesFromResource(R.xml.preference) // Git preferences - val gitServerPreference = findPreference<Preference>("git_server_info") - val openkeystoreIdPreference = findPreference<Preference>("ssh_openkeystore_clear_keyid") - val gitConfigPreference = findPreference<Preference>("git_config") - val sshKeyPreference = findPreference<Preference>("ssh_key") - val sshKeygenPreference = findPreference<Preference>("ssh_keygen") - clearSavedPassPreference = findPreference("clear_saved_pass") - val viewSshKeyPreference = findPreference<Preference>("ssh_see_key") - val deleteRepoPreference = findPreference<Preference>("git_delete_repo") - val externalGitRepositoryPreference = findPreference<Preference>("git_external") - val selectExternalGitRepositoryPreference = findPreference<Preference>("pref_select_external") + val gitServerPreference = findPreference<Preference>(PreferenceKeys.GIT_SERVER_INFO) + val openkeystoreIdPreference = findPreference<Preference>(PreferenceKeys.SSH_OPENKEYSTORE_CLEAR_KEY_ID) + val gitConfigPreference = findPreference<Preference>(PreferenceKeys.GIT_CONFIG) + val sshKeyPreference = findPreference<Preference>(PreferenceKeys.SSH_KEY) + val sshKeygenPreference = findPreference<Preference>(PreferenceKeys.SSH_KEYGEN) + clearSavedPassPreference = findPreference(PreferenceKeys.CLEAR_SAVED_PASS) + val viewSshKeyPreference = findPreference<Preference>(PreferenceKeys.SSH_SEE_KEY) + val deleteRepoPreference = findPreference<Preference>(PreferenceKeys.GIT_DELETE_REPO) + val externalGitRepositoryPreference = findPreference<Preference>(PreferenceKeys.GIT_EXTERNAL) + val selectExternalGitRepositoryPreference = findPreference<Preference>(PreferenceKeys.PREF_SELECT_EXTERNAL) if (!PasswordRepository.isGitRepo()) { listOfNotNull( @@ -109,21 +110,21 @@ class UserPreference : AppCompatActivity() { } // Crypto preferences - val keyPreference = findPreference<Preference>("openpgp_key_id_pref") + val keyPreference = findPreference<Preference>(PreferenceKeys.OPENPGP_KEY_ID_PREF) // General preferences - val showTimePreference = findPreference<Preference>("general_show_time") - val clearClipboard20xPreference = findPreference<CheckBoxPreference>("clear_clipboard_20x") + val showTimePreference = findPreference<Preference>(PreferenceKeys.GENERAL_SHOW_TIME) + val clearClipboard20xPreference = findPreference<CheckBoxPreference>(PreferenceKeys.CLEAR_CLIPBOARD_20X) // Autofill preferences - autoFillEnablePreference = findPreference("autofill_enable") - val oreoAutofillDirectoryStructurePreference = findPreference<ListPreference>("oreo_autofill_directory_structure") - val oreoAutofillDefaultUsername = findPreference<EditTextPreference>("oreo_autofill_default_username") - val oreoAutofillCustomPublixSuffixes = findPreference<EditTextPreference>("oreo_autofill_custom_public_suffixes") - val autoFillAppsPreference = findPreference<Preference>("autofill_apps") - val autoFillDefaultPreference = findPreference<CheckBoxPreference>("autofill_default") - val autoFillAlwaysShowDialogPreference = findPreference<CheckBoxPreference>("autofill_always") - val autoFillShowFullNamePreference = findPreference<CheckBoxPreference>("autofill_full_path") + autoFillEnablePreference = findPreference(PreferenceKeys.AUTOFILL_ENABLE) + val oreoAutofillDirectoryStructurePreference = findPreference<ListPreference>(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE) + val oreoAutofillDefaultUsername = findPreference<EditTextPreference>(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) + val oreoAutofillCustomPublixSuffixes = findPreference<EditTextPreference>(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) + val autoFillAppsPreference = findPreference<Preference>(PreferenceKeys.AUTOFILL_APPS) + val autoFillDefaultPreference = findPreference<CheckBoxPreference>(PreferenceKeys.AUTOFILL_DEFAULT) + val autoFillAlwaysShowDialogPreference = findPreference<CheckBoxPreference>(PreferenceKeys.AUTOFILL_ALWAYS) + val autoFillShowFullNamePreference = findPreference<CheckBoxPreference>(PreferenceKeys.AUTOFILL_FULL_PATH) autofillDependencies = listOfNotNull( autoFillAppsPreference, autoFillDefaultPreference, @@ -143,13 +144,13 @@ class UserPreference : AppCompatActivity() { } // Misc preferences - val appVersionPreference = findPreference<Preference>("app_version") + val appVersionPreference = findPreference<Preference>(PreferenceKeys.APP_VERSION) - selectExternalGitRepositoryPreference?.summary = sharedPreferences.getString("git_external_repo", getString(R.string.no_repo_selected)) - viewSshKeyPreference?.isVisible = sharedPreferences.getBoolean("use_generated_key", false) - deleteRepoPreference?.isVisible = !sharedPreferences.getBoolean("git_external", false) - clearClipboard20xPreference?.isVisible = sharedPreferences.getString("general_show_time", "45")?.toInt() != 0 - openkeystoreIdPreference?.isVisible = sharedPreferences.getString("ssh_openkeystore_keyid", null)?.isNotEmpty() + selectExternalGitRepositoryPreference?.summary = sharedPreferences.getString(PreferenceKeys.GIT_EXTERNAL_REPO, getString(R.string.no_repo_selected)) + viewSshKeyPreference?.isVisible = sharedPreferences.getBoolean(PreferenceKeys.USE_GENERATED_KEY, false) + deleteRepoPreference?.isVisible = !sharedPreferences.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) + clearClipboard20xPreference?.isVisible = sharedPreferences.getString(PreferenceKeys.GENERAL_SHOW_TIME, "45")?.toInt() != 0 + openkeystoreIdPreference?.isVisible = sharedPreferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null)?.isNotEmpty() ?: false updateAutofillSettings() @@ -160,7 +161,7 @@ class UserPreference : AppCompatActivity() { keyPreference?.let { pref -> updateKeyIDsSummary(pref) pref.onPreferenceClickListener = ClickListener { - val providerPackageName = requireNotNull(sharedPreferences.getString("openpgp_provider_list", "")) + val providerPackageName = requireNotNull(sharedPreferences.getString(PreferenceKeys.OPENPGP_PROVIDER_LIST, "")) if (providerPackageName.isEmpty()) { Snackbar.make(requireView(), resources.getString(R.string.provider_toast_text), Snackbar.LENGTH_LONG).show() false @@ -193,17 +194,17 @@ class UserPreference : AppCompatActivity() { clearSavedPassPreference?.onPreferenceClickListener = ClickListener { encryptedPreferences.edit { - if (encryptedPreferences.getString("https_password", null) != null) - remove("https_password") - else if (encryptedPreferences.getString("ssh_key_local_passphrase", null) != null) - remove("ssh_key_local_passphrase") + if (encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD, null) != null) + remove(PreferenceKeys.HTTPS_PASSWORD) + else if (encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE, null) != null) + remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) } updateClearSavedPassphrasePrefs() true } openkeystoreIdPreference?.onPreferenceClickListener = ClickListener { - sharedPreferences.edit { putString("ssh_openkeystore_keyid", null) } + sharedPreferences.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) } it.isVisible = false true } @@ -237,7 +238,7 @@ class UserPreference : AppCompatActivity() { removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList()) } } - sharedPreferences.edit { putBoolean("repository_initialized", false) } + sharedPreferences.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) } dialogInterface.cancel() callingActivity.finish() } @@ -248,7 +249,7 @@ class UserPreference : AppCompatActivity() { } selectExternalGitRepositoryPreference?.summary = - sharedPreferences.getString("git_external_repo", context.getString(R.string.no_repo_selected)) + sharedPreferences.getString(PreferenceKeys.GIT_EXTERNAL_REPO, context.getString(R.string.no_repo_selected)) selectExternalGitRepositoryPreference?.onPreferenceClickListener = ClickListener { callingActivity.selectExternalGitRepository() true @@ -257,7 +258,7 @@ class UserPreference : AppCompatActivity() { val resetRepo = Preference.OnPreferenceChangeListener { _, o -> deleteRepoPreference?.isVisible = !(o as Boolean) PasswordRepository.closeRepository() - sharedPreferences.edit { putBoolean("repo_changed", true) } + sharedPreferences.edit { putBoolean(PreferenceKeys.REPO_CHANGED, true) } true } @@ -275,8 +276,8 @@ class UserPreference : AppCompatActivity() { true } - findPreference<Preference>("export_passwords")?.apply { - isVisible = sharedPreferences.getBoolean("repository_initialized", false) + findPreference<Preference>(PreferenceKeys.EXPORT_PASSWORDS)?.apply { + isVisible = sharedPreferences.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) onPreferenceClickListener = Preference.OnPreferenceClickListener { callingActivity.exportPasswords() true @@ -294,12 +295,13 @@ class UserPreference : AppCompatActivity() { } showTimePreference?.summaryProvider = Preference.SummaryProvider<Preference> { - getString(R.string.pref_clipboard_timeout_summary, sharedPreferences.getString("general_show_time", "45")) + getString(R.string.pref_clipboard_timeout_summary, sharedPreferences.getString + (PreferenceKeys.GENERAL_SHOW_TIME, "45")) } - findPreference<CheckBoxPreference>("enable_debug_logging")?.isVisible = !BuildConfig.ENABLE_DEBUG_FEATURES + findPreference<CheckBoxPreference>(PreferenceKeys.ENABLE_DEBUG_LOGGING)?.isVisible = !BuildConfig.ENABLE_DEBUG_FEATURES - findPreference<CheckBoxPreference>("biometric_auth")?.apply { + findPreference<CheckBoxPreference>(PreferenceKeys.BIOMETRIC_AUTH)?.apply { val isFingerprintSupported = BiometricManager.from(requireContext()).canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS if (!isFingerprintSupported) { isEnabled = false @@ -314,13 +316,13 @@ class UserPreference : AppCompatActivity() { when (result) { is BiometricAuthenticator.Result.Success -> { // Apply the changes - putBoolean("biometric_auth", checked) + putBoolean(PreferenceKeys.BIOMETRIC_AUTH, checked) isEnabled = true } else -> { // If any error occurs, revert back to the previous state. This // catch-all clause includes the cancellation case. - putBoolean("biometric_auth", !checked) + putBoolean(PreferenceKeys.BIOMETRIC_AUTH, !checked) isChecked = !checked isEnabled = true } @@ -337,20 +339,20 @@ class UserPreference : AppCompatActivity() { } } - val prefCustomXkpwdDictionary = findPreference<Preference>("pref_key_custom_dict") + val prefCustomXkpwdDictionary = findPreference<Preference>(PreferenceKeys.PREF_KEY_CUSTOM_DICT) prefCustomXkpwdDictionary?.onPreferenceClickListener = ClickListener { callingActivity.storeCustomDictionaryPath() true } - val dictUri = sharedPreferences.getString("pref_key_custom_dict", "") + val dictUri = sharedPreferences.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT, "") if (!TextUtils.isEmpty(dictUri)) { setCustomDictSummary(prefCustomXkpwdDictionary, Uri.parse(dictUri)) } - val prefIsCustomDict = findPreference<CheckBoxPreference>("pref_key_is_custom_dict") - val prefCustomDictPicker = findPreference<Preference>("pref_key_custom_dict") - val prefPwgenType = findPreference<ListPreference>("pref_key_pwgen_type") + val prefIsCustomDict = findPreference<CheckBoxPreference>(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT) + val prefCustomDictPicker = findPreference<Preference>(PreferenceKeys.PREF_KEY_CUSTOM_DICT) + val prefPwgenType = findPreference<ListPreference>(PreferenceKeys.PREF_KEY_PWGEN_TYPE) updateXkPasswdPrefsVisibility(prefPwgenType?.value, prefIsCustomDict, prefCustomDictPicker) prefPwgenType?.onPreferenceChangeListener = ChangeListener { _, newValue -> @@ -371,7 +373,7 @@ class UserPreference : AppCompatActivity() { } private fun updateKeyIDsSummary(preference: Preference) { - val selectedKeys = (sharedPreferences.getStringSet("openpgp_key_ids_set", null) + val selectedKeys = (sharedPreferences.getStringSet(PreferenceKeys.OPENPGP_KEY_IDS_SET, null) ?: HashSet()).toTypedArray() preference.summary = if (selectedKeys.isEmpty()) { resources.getString(R.string.pref_no_key_selected) @@ -410,8 +412,8 @@ class UserPreference : AppCompatActivity() { private fun updateClearSavedPassphrasePrefs() { clearSavedPassPreference?.apply { - val sshPass = encryptedPreferences.getString("ssh_key_local_passphrase", null) - val httpsPass = encryptedPreferences.getString("https_password", null) + val sshPass = encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE, null) + val httpsPass = encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD, null) if (sshPass == null && httpsPass == null) { isVisible = false return@apply @@ -654,8 +656,8 @@ class UserPreference : AppCompatActivity() { ).show() val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) - prefs.edit { putBoolean("use_generated_key", false) } - getEncryptedPrefs("git_operation").edit { remove("ssh_key_local_passphrase") } + prefs.edit { putBoolean(PreferenceKeys.USE_GENERATED_KEY, false) } + getEncryptedPrefs("git_operation").edit { remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) } // Delete the public key from generation File("""$filesDir/.ssh_key.pub""").delete() @@ -688,12 +690,12 @@ class UserPreference : AppCompatActivity() { .setTitle(getString(R.string.sdcard_root_warning_title)) .setMessage(getString(R.string.sdcard_root_warning_message)) .setPositiveButton("Remove everything") { _, _ -> - prefs.edit { putString("git_external_repo", uri?.path) } + prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, uri?.path) } } .setNegativeButton(R.string.dialog_cancel, null) .show() } - prefs.edit { putString("git_external_repo", repoPath) } + prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, repoPath) } } EXPORT_PASSWORDS -> { val uri = data.data @@ -716,9 +718,9 @@ class UserPreference : AppCompatActivity() { ).show() val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) - prefs.edit { putString("pref_key_custom_dict", uri.toString()) } + prefs.edit { putString(PreferenceKeys.PREF_KEY_CUSTOM_DICT, uri.toString()) } - val customDictPref = prefsFragment.findPreference<Preference>("pref_key_custom_dict") + val customDictPref = prefsFragment.findPreference<Preference>(PreferenceKeys.PREF_KEY_CUSTOM_DICT) setCustomDictSummary(customDictPref, uri) // copy user selected file to internal storage val inputStream = contentResolver.openInputStream(uri) diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillService.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillService.kt index 26c86fc8..177233a8 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillService.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillService.kt @@ -28,9 +28,10 @@ import com.github.ajalt.timberkt.Timber.tag import com.github.ajalt.timberkt.e import com.github.ajalt.timberkt.i import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zeapo.pwdstore.PasswordEntry import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.model.PasswordEntry import com.zeapo.pwdstore.utils.PasswordRepository +import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.splitLines import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -228,7 +229,7 @@ class AutofillService : AccessibilityService(), CoroutineScope by CoroutineScope // if autofill_always checked, show dialog even if no matches (automatic // or otherwise) - if (items.isEmpty() && !settings!!.getBoolean("autofill_always", false)) { + if (items.isEmpty() && !settings!!.getBoolean(PreferenceKeys.AUTOFILL_ALWAYS, false)) { return } showSelectPasswordDialog(packageName, appName, isWeb) @@ -268,7 +269,7 @@ class AutofillService : AccessibilityService(), CoroutineScope by CoroutineScope var settingsURL = webViewURL // if autofill_default is checked and prefs.getString DNE, 'Automatically match with password'/"first" otherwise "never" - val defValue = if (settings!!.getBoolean("autofill_default", true)) "/first" else "/never" + val defValue = if (settings!!.getBoolean(PreferenceKeys.AUTOFILL_DEFAULT, true)) "/first" else "/never" val prefs: SharedPreferences = getSharedPreferences("autofill_web", Context.MODE_PRIVATE) var preference: String @@ -305,7 +306,7 @@ class AutofillService : AccessibilityService(), CoroutineScope by CoroutineScope private fun setAppMatchingPasswords(appName: String, packageName: String) { // if autofill_default is checked and prefs.getString DNE, 'Automatically match with password'/"first" otherwise "never" - val defValue = if (settings!!.getBoolean("autofill_default", true)) "/first" else "/never" + val defValue = if (settings!!.getBoolean(PreferenceKeys.AUTOFILL_DEFAULT, true)) "/first" else "/never" val prefs: SharedPreferences = getSharedPreferences("autofill", Context.MODE_PRIVATE) val preference: String? @@ -414,7 +415,7 @@ class AutofillService : AccessibilityService(), CoroutineScope by CoroutineScope // make it optional (or make height a setting for the same effect) val itemNames = arrayOfNulls<CharSequence>(items.size + 2) val passwordDirectory = PasswordRepository.getRepositoryDirectory(applicationContext).toString() - val autofillFullPath = settings!!.getBoolean("autofill_full_path", false) + val autofillFullPath = settings!!.getBoolean(PreferenceKeys.AUTOFILL_FULL_PATH, false) for (i in items.indices) { if (autofillFullPath) { itemNames[i] = items[i].path.replace(".gpg", "") @@ -518,7 +519,7 @@ class AutofillService : AccessibilityService(), CoroutineScope by CoroutineScope // save password entry for pasting the username as well if (entry?.hasUsername() == true) { lastPassword = entry - val ttl = Integer.parseInt(settings!!.getString("general_show_time", "45")!!) + val ttl = Integer.parseInt(settings!!.getString(PreferenceKeys.GENERAL_SHOW_TIME, "45")!!) withContext(Dispatchers.Main) { Toast.makeText(applicationContext, getString(R.string.autofill_toast_username, ttl), Toast.LENGTH_LONG).show() } lastPasswordMaxDate = System.currentTimeMillis() + ttl * 1000L } diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt index 101f96a0..838b7a05 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt @@ -19,9 +19,10 @@ import androidx.annotation.RequiresApi import androidx.preference.PreferenceManager import com.github.ajalt.timberkt.Timber.tag import com.github.ajalt.timberkt.e -import com.zeapo.pwdstore.PasswordEntry import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.model.PasswordEntry import com.zeapo.pwdstore.utils.PasswordRepository +import com.zeapo.pwdstore.utils.PreferenceKeys import java.io.File import java.security.MessageDigest @@ -39,7 +40,7 @@ private fun ByteArray.base64(): String { private fun Context.getDefaultUsername(): String? { return PreferenceManager .getDefaultSharedPreferences(this) - .getString("oreo_autofill_default_username", null) + .getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME, null) } private fun stableHash(array: Collection<ByteArray>): String { @@ -86,7 +87,7 @@ val AssistStructure.ViewNode.webOrigin: String? "$scheme://$domain" } -data class Credentials(val username: String?, val password: String) { +data class Credentials(val username: String?, val password: String, val otp: String?) { companion object { fun fromStoreEntry( context: Context, @@ -98,7 +99,7 @@ data class Credentials(val username: String?, val password: String) { val username = entry.username ?: directoryStructure.getUsernameFor(file) ?: context.getDefaultUsername() - return Credentials(username, entry.password) + return Credentials(username, entry.password, entry.calculateTotpCode()) } } } diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillScenario.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillScenario.kt index f1514851..8e209a60 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillScenario.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillScenario.kt @@ -29,6 +29,7 @@ sealed class AutofillScenario<out T : Any> { companion object { const val BUNDLE_KEY_USERNAME_ID = "usernameId" const val BUNDLE_KEY_FILL_USERNAME = "fillUsername" + const val BUNDLE_KEY_OTP_ID = "otpId" const val BUNDLE_KEY_CURRENT_PASSWORD_IDS = "currentPasswordIds" const val BUNDLE_KEY_NEW_PASSWORD_IDS = "newPasswordIds" const val BUNDLE_KEY_GENERIC_PASSWORD_IDS = "genericPasswordIds" @@ -38,6 +39,7 @@ sealed class AutofillScenario<out T : Any> { Builder<AutofillId>().apply { username = clientState.getParcelable(BUNDLE_KEY_USERNAME_ID) fillUsername = clientState.getBoolean(BUNDLE_KEY_FILL_USERNAME) + otp = clientState.getParcelable(BUNDLE_KEY_OTP_ID) currentPassword.addAll( clientState.getParcelableArrayList( BUNDLE_KEY_CURRENT_PASSWORD_IDS @@ -64,6 +66,7 @@ sealed class AutofillScenario<out T : Any> { class Builder<T : Any> { var username: T? = null var fillUsername = false + var otp: T? = null val currentPassword = mutableListOf<T>() val newPassword = mutableListOf<T>() val genericPassword = mutableListOf<T>() @@ -74,6 +77,7 @@ sealed class AutofillScenario<out T : Any> { ClassifiedAutofillScenario( username = username, fillUsername = fillUsername, + otp = otp, currentPassword = currentPassword, newPassword = newPassword ) @@ -81,6 +85,7 @@ sealed class AutofillScenario<out T : Any> { GenericAutofillScenario( username = username, fillUsername = fillUsername, + otp = otp, genericPassword = genericPassword ) } @@ -89,6 +94,7 @@ sealed class AutofillScenario<out T : Any> { abstract val username: T? abstract val fillUsername: Boolean + abstract val otp: T? abstract val allPasswordFields: List<T> abstract val passwordFieldsToFillOnMatch: List<T> abstract val passwordFieldsToFillOnSearch: List<T> @@ -99,19 +105,19 @@ sealed class AutofillScenario<out T : Any> { get() = listOfNotNull(username) + passwordFieldsToSave val allFields - get() = listOfNotNull(username) + allPasswordFields + get() = listOfNotNull(username, otp) + allPasswordFields fun fieldsToFillOn(action: AutofillAction): List<T> { - val passwordFieldsToFill = when (action) { - AutofillAction.Match -> passwordFieldsToFillOnMatch - AutofillAction.Search -> passwordFieldsToFillOnSearch + val credentialFieldsToFill = when (action) { + AutofillAction.Match -> passwordFieldsToFillOnMatch + listOfNotNull(otp) + AutofillAction.Search -> passwordFieldsToFillOnSearch + listOfNotNull(otp) AutofillAction.Generate -> passwordFieldsToFillOnGenerate } return when { - passwordFieldsToFill.isNotEmpty() -> { + credentialFieldsToFill.isNotEmpty() -> { // If the current action would fill into any password field, we also fill into the // username field if possible. - listOfNotNull(username.takeIf { fillUsername }) + passwordFieldsToFill + listOfNotNull(username.takeIf { fillUsername }) + credentialFieldsToFill } allPasswordFields.isEmpty() && action != AutofillAction.Generate -> { // If there no password fields at all, we still offer to fill the username, e.g. in @@ -127,6 +133,7 @@ sealed class AutofillScenario<out T : Any> { data class ClassifiedAutofillScenario<T : Any>( override val username: T?, override val fillUsername: Boolean, + override val otp: T?, val currentPassword: List<T>, val newPassword: List<T> ) : AutofillScenario<T>() { @@ -147,6 +154,7 @@ data class ClassifiedAutofillScenario<T : Any>( data class GenericAutofillScenario<T : Any>( override val username: T?, override val fillUsername: Boolean, + override val otp: T?, val genericPassword: List<T> ) : AutofillScenario<T>() { @@ -183,14 +191,15 @@ fun Dataset.Builder.fillWith( ) { val credentialsToFill = credentials ?: Credentials( "USERNAME", - "PASSWORD" + "PASSWORD", + "OTP" ) for (field in scenario.fieldsToFillOn(action)) { - val value = if (field == scenario.username) { - credentialsToFill.username - } else { - credentialsToFill.password - } ?: continue + val value = when (field) { + scenario.username -> credentialsToFill.username + scenario.otp -> credentialsToFill.otp + else -> credentialsToFill.password + } setValue(field, AutofillValue.forText(value)) } } @@ -209,6 +218,7 @@ inline fun <T : Any, S : Any> AutofillScenario<T>.map(transform: (T) -> S): Auto val builder = AutofillScenario.Builder<S>() builder.username = username?.let(transform) builder.fillUsername = fillUsername + builder.otp = otp?.let(transform) when (this) { is ClassifiedAutofillScenario -> { builder.currentPassword.addAll(currentPassword.map(transform)) @@ -225,9 +235,10 @@ inline fun <T : Any, S : Any> AutofillScenario<T>.map(transform: (T) -> S): Auto @JvmName("toBundleAutofillId") private fun AutofillScenario<AutofillId>.toBundle(): Bundle = when (this) { is ClassifiedAutofillScenario<AutofillId> -> { - Bundle(4).apply { + Bundle(5).apply { putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username) putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername) + putParcelable(AutofillScenario.BUNDLE_KEY_OTP_ID, otp) putParcelableArrayList( AutofillScenario.BUNDLE_KEY_CURRENT_PASSWORD_IDS, ArrayList(currentPassword) ) @@ -237,9 +248,10 @@ private fun AutofillScenario<AutofillId>.toBundle(): Bundle = when (this) { } } is GenericAutofillScenario<AutofillId> -> { - Bundle(3).apply { + Bundle(4).apply { putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username) putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername) + putParcelable(AutofillScenario.BUNDLE_KEY_OTP_ID, otp) putParcelableArrayList( AutofillScenario.BUNDLE_KEY_GENERIC_PASSWORD_IDS, ArrayList(genericPassword) ) diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategy.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategy.kt index 6f3b4ff5..90bb7051 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategy.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategy.kt @@ -30,18 +30,23 @@ val autofillStrategy = strategy { // TODO: Introduce a custom fill/generate/update flow for this scenario rule { newPassword { - takePair { all { hasAutocompleteHintNewPassword } } + takePair { all { hasHintNewPassword } } breakTieOnPair { any { isFocused } } } currentPassword(optional = true) { takeSingle { alreadyMatched -> val adjacentToNewPasswords = directlyPrecedes(alreadyMatched) || directlyFollows(alreadyMatched) - hasAutocompleteHintCurrentPassword && adjacentToNewPasswords + // The Autofill framework has not hint that applies to current passwords only. + // In this scenario, we have already matched fields a pair of fields with a specific + // new password hint, so we take a generic Autofill password hint to mean a current + // password. + (hasAutocompleteHintCurrentPassword || hasAutofillHintPassword) && + adjacentToNewPasswords } } username(optional = true) { - takeSingle { hasAutocompleteHintUsername } + takeSingle { hasHintUsername } breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) } breakTieOnSingle { isFocused } } @@ -73,7 +78,7 @@ val autofillStrategy = strategy { breakTieOnSingle { isFocused } } username(optional = true) { - takeSingle { hasAutocompleteHintUsername } + takeSingle { hasHintUsername } breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) } breakTieOnSingle { isFocused } } @@ -115,7 +120,7 @@ val autofillStrategy = strategy { // field. rule(applyInSingleOriginMode = true) { newPassword { - takeSingle { hasAutocompleteHintNewPassword && isFocused } + takeSingle { hasHintNewPassword && isFocused } } username(optional = true) { takeSingle { alreadyMatched -> @@ -157,7 +162,7 @@ val autofillStrategy = strategy { // filling of hidden password fields to scenarios where this is clearly warranted. rule { username { - takeSingle { hasAutocompleteHintUsername && isFocused } + takeSingle { hasHintUsername && isFocused } } currentPassword(matchHidden = true) { takeSingle { alreadyMatched -> @@ -166,12 +171,19 @@ val autofillStrategy = strategy { } } + // Match a single focused OTP field. + rule(applyInSingleOriginMode = true) { + otp { + takeSingle { otpCertainty >= Likely && isFocused } + } + } + // Match a single focused username field without a password field. rule(applyInSingleOriginMode = true) { username { takeSingle { usernameCertainty >= Likely && isFocused } breakTieOnSingle { usernameCertainty >= Certain } - breakTieOnSingle { hasAutocompleteHintUsername } + breakTieOnSingle { hasHintUsername } } } diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategyDsl.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategyDsl.kt index 3b648234..5e6f460e 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategyDsl.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategyDsl.kt @@ -164,7 +164,7 @@ class AutofillRule private constructor( ) enum class FillableFieldType { - Username, CurrentPassword, NewPassword, GenericPassword, + Username, Otp, CurrentPassword, NewPassword, GenericPassword, } @AutofillDsl @@ -192,6 +192,18 @@ class AutofillRule private constructor( ) } + fun otp(optional: Boolean = false, block: SingleFieldMatcher.Builder.() -> Unit) { + require(matchers.none { it.type == FillableFieldType.Otp }) { "Every rule block can only have at most one otp block" } + matchers.add( + AutofillRuleMatcher( + type = FillableFieldType.Otp, + matcher = SingleFieldMatcher.Builder().apply(block).build(), + optional = optional, + matchHidden = false + ) + ) + } + fun currentPassword(optional: Boolean = false, matchHidden: Boolean = false, block: FieldMatcher.Builder.() -> Unit) { require(matchers.none { it.type == FillableFieldType.GenericPassword }) { "Every rule block can only have either genericPassword or {current,new}Password blocks" } matchers.add( @@ -247,6 +259,7 @@ class AutofillRule private constructor( fun match( allPassword: List<FormField>, allUsername: List<FormField>, + allOtp: List<FormField>, singleOriginMode: Boolean, isManualRequest: Boolean ): AutofillScenario<FormField>? { @@ -264,6 +277,7 @@ class AutofillRule private constructor( for ((type, matcher, optional, matchHidden) in matchers) { val fieldsToMatchOn = when (type) { FillableFieldType.Username -> allUsername + FillableFieldType.Otp -> allOtp else -> allPassword }.filter { matchHidden || it.isVisible } val matchResult = matcher.match(fieldsToMatchOn, alreadyMatched) ?: if (optional) { @@ -281,6 +295,10 @@ class AutofillRule private constructor( // Hidden username fields should be saved but not filled. scenarioBuilder.fillUsername = scenarioBuilder.username!!.isVisible == true } + FillableFieldType.Otp -> { + check(matchResult.size == 1 && scenarioBuilder.otp == null) + scenarioBuilder.otp = matchResult.single() + } FillableFieldType.CurrentPassword -> scenarioBuilder.currentPassword.addAll( matchResult ) @@ -338,12 +356,16 @@ class AutofillStrategy private constructor(private val rules: List<AutofillRule> val possibleUsernameFields = fields.filter { it.usernameCertainty >= CertaintyLevel.Possible } d { "Possible username fields: ${possibleUsernameFields.size}" } + val possibleOtpFields = + fields.filter { it.otpCertainty >= CertaintyLevel.Possible } + d { "Possible otp fields: ${possibleOtpFields.size}" } // Return the result of the first rule that matches d { "Rules: ${rules.size}" } for (rule in rules) { return rule.match( possiblePasswordFields, possibleUsernameFields, + possibleOtpFields, singleOriginMode = singleOriginMode, isManualRequest = isManualRequest ) diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FormField.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FormField.kt index 0c96b587..2b18bbb6 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FormField.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FormField.kt @@ -10,6 +10,7 @@ import android.text.InputType import android.view.View import android.view.autofill.AutofillId import androidx.annotation.RequiresApi +import androidx.autofill.HintConstants import java.util.Locale enum class CertaintyLevel { @@ -30,15 +31,30 @@ class FormField( companion object { - @RequiresApi(Build.VERSION_CODES.O) - private val HINTS_USERNAME = listOf(View.AUTOFILL_HINT_USERNAME) + private val HINTS_USERNAME = listOf( + HintConstants.AUTOFILL_HINT_USERNAME, + HintConstants.AUTOFILL_HINT_NEW_USERNAME + ) - @RequiresApi(Build.VERSION_CODES.O) - private val HINTS_PASSWORD = listOf(View.AUTOFILL_HINT_PASSWORD) + private val HINTS_NEW_PASSWORD = listOf( + HintConstants.AUTOFILL_HINT_NEW_PASSWORD + ) - @RequiresApi(Build.VERSION_CODES.O) - private val HINTS_FILLABLE = HINTS_USERNAME + HINTS_PASSWORD + listOf( - View.AUTOFILL_HINT_EMAIL_ADDRESS, View.AUTOFILL_HINT_NAME, View.AUTOFILL_HINT_PHONE + private val HINTS_PASSWORD = HINTS_NEW_PASSWORD + listOf( + HintConstants.AUTOFILL_HINT_PASSWORD + ) + + private val HINTS_OTP = listOf( + HintConstants.AUTOFILL_HINT_SMS_OTP + ) + + @Suppress("DEPRECATION") + private val HINTS_FILLABLE = HINTS_USERNAME + HINTS_PASSWORD + HINTS_OTP + listOf( + HintConstants.AUTOFILL_HINT_EMAIL_ADDRESS, + HintConstants.AUTOFILL_HINT_NAME, + HintConstants.AUTOFILL_HINT_PERSON_NAME, + HintConstants.AUTOFILL_HINT_PHONE, + HintConstants.AUTOFILL_HINT_PHONE_NUMBER ) private val ANDROID_TEXT_FIELD_CLASS_NAMES = listOf( @@ -67,17 +83,20 @@ class FormField( private val HTML_INPUT_FIELD_TYPES_USERNAME = listOf("email", "tel", "text") private val HTML_INPUT_FIELD_TYPES_PASSWORD = listOf("password") + private val HTML_INPUT_FIELD_TYPES_OTP = listOf("tel", "text") private val HTML_INPUT_FIELD_TYPES_FILLABLE = - HTML_INPUT_FIELD_TYPES_USERNAME + HTML_INPUT_FIELD_TYPES_PASSWORD + (HTML_INPUT_FIELD_TYPES_USERNAME + HTML_INPUT_FIELD_TYPES_PASSWORD + HTML_INPUT_FIELD_TYPES_OTP).toSet().toList() @RequiresApi(Build.VERSION_CODES.O) - private fun isSupportedHint(hint: String) = hint in HINTS_USERNAME + HINTS_PASSWORD + private fun isSupportedHint(hint: String) = hint in HINTS_FILLABLE private val EXCLUDED_TERMS = listOf( "url_bar", // Chrome/Edge/Firefox address bar "url_field", // Opera address bar "location_bar_edit_text", // Samsung address bar - "search", "find", "captcha" + "search", "find", "captcha", + "postal" // Prevent postal code fields from being mistaken for OTP fields + ) private val PASSWORD_HEURISTIC_TERMS = listOf( "pass", "pswd", "pwd" @@ -85,8 +104,19 @@ class FormField( private val USERNAME_HEURISTIC_TERMS = listOf( "alias", "e-mail", "email", "login", "user" ) + private val OTP_HEURISTIC_TERMS = listOf( + "einmal", "otp" + ) + private val OTP_WEAK_HEURISTIC_TERMS = listOf( + "code" + ) } + private val List<String>.anyMatchesFieldInfo + get() = any { + fieldId.contains(it) || hint.contains(it) || htmlName.contains(it) + } + val autofillId: AutofillId = node.autofillId!! // Information for heuristics and exclusion rules based only on the current field @@ -120,6 +150,7 @@ class FormField( htmlAttributes.entries.joinToString { "${it.key}=${it.value}" } private val htmlInputType = htmlAttributes["type"] private val htmlName = htmlAttributes["name"] ?: "" + private val htmlMaxLength = htmlAttributes["maxlength"]?.toIntOrNull() private val isHtmlField = htmlTag == "input" private val isHtmlPasswordField = isHtmlField && htmlInputType in HTML_INPUT_FIELD_TYPES_PASSWORD @@ -138,19 +169,28 @@ class FormField( private val autofillHints = node.autofillHints?.filter { isSupportedHint(it) } ?: emptyList() private val excludedByAutofillHints = if (autofillHints.isEmpty()) false else autofillHints.intersect(HINTS_FILLABLE).isEmpty() - private val hasAutofillHintPassword = autofillHints.intersect(HINTS_PASSWORD).isNotEmpty() + val hasAutofillHintPassword = autofillHints.intersect(HINTS_PASSWORD).isNotEmpty() + private val hasAutofillHintNewPassword = autofillHints.intersect(HINTS_NEW_PASSWORD).isNotEmpty() private val hasAutofillHintUsername = autofillHints.intersect(HINTS_USERNAME).isNotEmpty() + private val hasAutofillHintOtp = autofillHints.intersect(HINTS_OTP).isNotEmpty() // W3C autocomplete hint detection for HTML fields private val htmlAutocomplete = htmlAttributes["autocomplete"] // Ignored for now, see excludedByHints private val excludedByAutocompleteHint = htmlAutocomplete == "off" - val hasAutocompleteHintUsername = htmlAutocomplete == "username" + private val hasAutocompleteHintUsername = htmlAutocomplete == "username" val hasAutocompleteHintCurrentPassword = htmlAutocomplete == "current-password" - val hasAutocompleteHintNewPassword = htmlAutocomplete == "new-password" + private val hasAutocompleteHintNewPassword = htmlAutocomplete == "new-password" private val hasAutocompleteHintPassword = hasAutocompleteHintCurrentPassword || hasAutocompleteHintNewPassword + private val hasAutocompleteHintOtp = htmlAutocomplete == "one-time-code" + + // Results of hint-based field type detection + val hasHintUsername = hasAutofillHintUsername || hasAutocompleteHintUsername + val hasHintPassword = hasAutofillHintPassword || hasAutocompleteHintPassword + val hasHintNewPassword = hasAutofillHintNewPassword || hasAutocompleteHintNewPassword + val hasHintOtp = hasAutofillHintOtp || hasAutocompleteHintOtp // Basic autofill exclusion checks private val hasAutofillTypeText = node.autofillType == View.AUTOFILL_TYPE_TEXT @@ -176,30 +216,34 @@ class FormField( val relevantField = isTextField && hasAutofillTypeText && !excludedByHints - // Exclude fields based on hint and resource ID + // Exclude fields based on hint, resource ID or HTML name. // Note: We still report excluded fields as relevant since they count for adjacency heuristics, // but ensure that they are never detected as password or username fields. - private val hasExcludedTerm = EXCLUDED_TERMS.any { fieldId.contains(it) || hint.contains(it) } + private val hasExcludedTerm = EXCLUDED_TERMS.anyMatchesFieldInfo private val notExcluded = relevantField && !hasExcludedTerm // Password field heuristics (based only on the current field) private val isPossiblePasswordField = notExcluded && (isAndroidPasswordField || isHtmlPasswordField) - private val isCertainPasswordField = - isPossiblePasswordField && (isHtmlPasswordField || hasAutofillHintPassword || hasAutocompleteHintPassword) - private val isLikelyPasswordField = isPossiblePasswordField && (isCertainPasswordField || (PASSWORD_HEURISTIC_TERMS.any { - fieldId.contains(it) || hint.contains(it) || htmlName.contains(it) - })) + private val isCertainPasswordField = isPossiblePasswordField && hasHintPassword + private val isLikelyPasswordField = isPossiblePasswordField && + (isCertainPasswordField || PASSWORD_HEURISTIC_TERMS.anyMatchesFieldInfo) val passwordCertainty = if (isCertainPasswordField) CertaintyLevel.Certain else if (isLikelyPasswordField) CertaintyLevel.Likely else if (isPossiblePasswordField) CertaintyLevel.Possible else CertaintyLevel.Impossible + // OTP field heuristics (based only on the current field) + private val isPossibleOtpField = notExcluded && !isPossiblePasswordField && isTextField + private val isCertainOtpField = isPossibleOtpField && hasHintOtp + private val isLikelyOtpField = isPossibleOtpField && ( + isCertainOtpField || OTP_HEURISTIC_TERMS.anyMatchesFieldInfo || + ((htmlMaxLength == null || htmlMaxLength in 6..8) && OTP_WEAK_HEURISTIC_TERMS.anyMatchesFieldInfo)) + val otpCertainty = + if (isCertainOtpField) CertaintyLevel.Certain else if (isLikelyOtpField) CertaintyLevel.Likely else if (isPossibleOtpField) CertaintyLevel.Possible else CertaintyLevel.Impossible + // Username field heuristics (based only on the current field) - private val isPossibleUsernameField = notExcluded && !isPossiblePasswordField - private val isCertainUsernameField = - isPossibleUsernameField && (hasAutofillHintUsername || hasAutocompleteHintUsername) - private val isLikelyUsernameField = isPossibleUsernameField && (isCertainUsernameField || (USERNAME_HEURISTIC_TERMS.any { - fieldId.contains(it) || hint.contains(it) || htmlName.contains(it) - })) + private val isPossibleUsernameField = notExcluded && !isPossiblePasswordField && !isCertainOtpField && isTextField + private val isCertainUsernameField = isPossibleUsernameField && hasHintUsername + private val isLikelyUsernameField = isPossibleUsernameField && (isCertainUsernameField || (USERNAME_HEURISTIC_TERMS.anyMatchesFieldInfo)) val usernameCertainty = if (isCertainUsernameField) CertaintyLevel.Certain else if (isLikelyUsernameField) CertaintyLevel.Likely else if (isPossibleUsernameField) CertaintyLevel.Possible else CertaintyLevel.Impossible @@ -224,8 +268,8 @@ class FormField( override fun toString(): String { val field = if (isHtmlTextField) "$htmlTag[type=$htmlInputType]" else className val description = - "\"$hint\", \"$fieldId\"${if (isFocused) ", focused" else ""}${if (isVisible) ", visible" else ""}, $webOrigin, $htmlAttributesDebug" - return "$field ($description): password=$passwordCertainty, username=$usernameCertainty" + "\"$hint\", \"$fieldId\"${if (isFocused) ", focused" else ""}${if (isVisible) ", visible" else ""}, $webOrigin, $htmlAttributesDebug, $autofillHints" + return "$field ($description): password=$passwordCertainty, username=$usernameCertainty, otp=$otpCertainty" } override fun equals(other: Any?): Boolean { diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt index 350d187b..cdf9a8ff 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt @@ -106,7 +106,7 @@ class OreoAutofillService : AutofillService() { callback.onSuccess( AutofillSaveActivity.makeSaveIntentSender( this, - credentials = Credentials(username, password), + credentials = Credentials(username, password, null), formOrigin = formOrigin ) ) diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/PublicSuffixListCache.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/PublicSuffixListCache.kt index f1ce6bce..349f0a1b 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/PublicSuffixListCache.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/PublicSuffixListCache.kt @@ -7,6 +7,7 @@ package com.zeapo.pwdstore.autofill.oreo import android.content.Context import android.util.Patterns import androidx.preference.PreferenceManager +import com.zeapo.pwdstore.utils.PreferenceKeys import kotlinx.coroutines.runBlocking import mozilla.components.lib.publicsuffixlist.PublicSuffixList @@ -67,7 +68,7 @@ fun getSuffixPlusUpToOne(domain: String, suffix: String): String? { fun getCustomSuffixes(context: Context): Sequence<String> { val prefs = PreferenceManager.getDefaultSharedPreferences(context) - return prefs.getString("oreo_autofill_custom_public_suffixes", "")!! + return prefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES, "")!! .splitToSequence('\n') .filter { it.isNotBlank() && it.first() != '.' && it.last() != '.' } } diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt index 4c806dff..d7d8daaf 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt @@ -16,12 +16,12 @@ import android.widget.Toast import androidx.annotation.RequiresApi import com.github.ajalt.timberkt.d import com.github.ajalt.timberkt.e -import com.zeapo.pwdstore.PasswordEntry import com.zeapo.pwdstore.autofill.oreo.AutofillAction import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences import com.zeapo.pwdstore.autofill.oreo.Credentials import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure import com.zeapo.pwdstore.autofill.oreo.FillableForm +import com.zeapo.pwdstore.model.PasswordEntry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -100,7 +100,7 @@ class AutofillDecryptActivity : Activity(), CoroutineScope { directoryStructure = AutofillPreferences.directoryStructure(this) d { action.toString() } launch { - val credentials = decryptUsernameAndPassword(File(filePath)) + val credentials = decryptCredential(File(filePath)) if (credentials == null) { setResult(RESULT_CANCELED) } else { @@ -153,7 +153,7 @@ class AutofillDecryptActivity : Activity(), CoroutineScope { } } - private suspend fun decryptUsernameAndPassword( + private suspend fun decryptCredential( file: File, resumeIntent: Intent? = null ): Credentials? { @@ -178,6 +178,7 @@ class AutofillDecryptActivity : Activity(), CoroutineScope { OpenPgpApi.RESULT_CODE_SUCCESS -> { try { val entry = withContext(Dispatchers.IO) { + @Suppress("BlockingMethodInNonBlockingContext") PasswordEntry(decryptedOutput) } Credentials.fromStoreEntry(this, file, entry, directoryStructure) @@ -203,7 +204,7 @@ class AutofillDecryptActivity : Activity(), CoroutineScope { ) } } - decryptUsernameAndPassword(file, intentToResume) + decryptCredential(file, intentToResume) } catch (e: Exception) { e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed with user interaction" } null diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt index fad13ec8..b5bd9e38 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt @@ -130,7 +130,7 @@ class AutofillSaveActivity : Activity() { finish() return } - val credentials = Credentials(username, password) + val credentials = Credentials(username, password, null) val fillInDataset = FillableForm.makeFillInDataset( this, credentials, diff --git a/app/src/main/java/com/zeapo/pwdstore/crypto/BasePgpActivity.kt b/app/src/main/java/com/zeapo/pwdstore/crypto/BasePgpActivity.kt index 5206a15f..88ba20f1 100644 --- a/app/src/main/java/com/zeapo/pwdstore/crypto/BasePgpActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/crypto/BasePgpActivity.kt @@ -17,6 +17,7 @@ import android.view.WindowManager import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.annotation.CallSuper +import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import androidx.preference.PreferenceManager import com.github.ajalt.timberkt.Timber.tag @@ -26,6 +27,7 @@ import com.google.android.material.snackbar.Snackbar import com.zeapo.pwdstore.ClipboardService import com.zeapo.pwdstore.R import com.zeapo.pwdstore.UserPreference +import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.clipboard import com.zeapo.pwdstore.utils.snackbar import me.msfjarvis.openpgpktx.util.OpenPgpApi @@ -93,7 +95,7 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) tag(TAG) - keyIDs = settings.getStringSet("openpgp_key_ids_set", null) ?: emptySet() + keyIDs = settings.getStringSet(PreferenceKeys.OPENPGP_KEY_IDS_SET, null) ?: emptySet() } /** @@ -132,7 +134,7 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou * [startActivityForResult]. */ fun bindToOpenKeychain(onBoundListener: OpenPgpServiceConnection.OnBound, activityResult: ActivityResultLauncher<Intent>) { - val providerPackageName = settings.getString("openpgp_provider_list", "") + val providerPackageName = settings.getString(PreferenceKeys.OPENPGP_PROVIDER_LIST, "") if (providerPackageName.isNullOrEmpty()) { Toast.makeText(this, resources.getString(R.string.provider_toast_text), Toast.LENGTH_LONG).show() activityResult.launch(Intent(this, UserPreference::class.java)) @@ -163,6 +165,7 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou return DateUtils.getRelativeTimeSpanString(this, timeStamp, true) } + /** * Base handling of OpenKeychain errors based on the error contained in [result]. Subclasses * can use this when they want to default to sane error handling. @@ -190,12 +193,16 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou * Copies provided [text] to the clipboard. Shows a [Snackbar] which can be disabled by passing * [showSnackbar] as false. */ - fun copyTextToClipboard(text: String?, showSnackbar: Boolean = true) { + fun copyTextToClipboard( + text: String?, + showSnackbar: Boolean = true, + @StringRes snackbarTextRes: Int = R.string.clipboard_copied_text + ) { val clipboard = clipboard ?: return val clip = ClipData.newPlainText("pgp_handler_result_pm", text) clipboard.setPrimaryClip(clip) if (showSnackbar) { - snackbar(message = resources.getString(R.string.clipboard_copied_text)) + snackbar(message = resources.getString(snackbarTextRes)) } } @@ -209,7 +216,8 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou var clearAfter = 45 try { - clearAfter = (settings.getString("general_show_time", "45") ?: "45").toInt() + clearAfter = (settings.getString(PreferenceKeys.GENERAL_SHOW_TIME, "45") + ?: "45").toInt() } catch (_: NumberFormatException) { } diff --git a/app/src/main/java/com/zeapo/pwdstore/crypto/DecryptActivity.kt b/app/src/main/java/com/zeapo/pwdstore/crypto/DecryptActivity.kt index b7d7adcd..3c31a518 100644 --- a/app/src/main/java/com/zeapo/pwdstore/crypto/DecryptActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/crypto/DecryptActivity.kt @@ -17,17 +17,24 @@ import androidx.activity.result.contract.ActivityResultContracts.StartActivityFo import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult import androidx.lifecycle.lifecycleScope import com.github.ajalt.timberkt.e -import com.zeapo.pwdstore.PasswordEntry import com.zeapo.pwdstore.R import com.zeapo.pwdstore.databinding.DecryptLayoutBinding +import com.zeapo.pwdstore.model.PasswordEntry +import com.zeapo.pwdstore.utils.Otp +import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.viewBinding import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import me.msfjarvis.openpgpktx.util.OpenPgpApi import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection import org.openintents.openpgp.IOpenPgpService2 import java.io.ByteArrayOutputStream import java.io.File +import java.util.Date +import kotlin.time.ExperimentalTime +import kotlin.time.seconds class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { private val binding by viewBinding(DecryptLayoutBinding::inflate) @@ -125,6 +132,7 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to))) } + @OptIn(ExperimentalTime::class) private fun decryptAndVerify(receivedIntent: Intent? = null) { if (api == null) { bindToOpenKeychain(this, openKeychainResult) @@ -141,8 +149,8 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { OpenPgpApi.RESULT_CODE_SUCCESS -> { try { - val showPassword = settings.getBoolean("show_password", true) - val showExtraContent = settings.getBoolean("show_extra_content", true) + val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true) + val showExtraContent = settings.getBoolean(PreferenceKeys.SHOW_EXTRA_CONTENT, true) val monoTypeface = Typeface.createFromAsset(assets, "fonts/sourcecodepro.ttf") val entry = PasswordEntry(outputStream) @@ -163,14 +171,16 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { } if (entry.hasExtraContent()) { - extraContentContainer.visibility = View.VISIBLE - extraContent.typeface = monoTypeface - extraContent.setText(entry.extraContentWithoutUsername) - if (!showExtraContent) { - extraContent.transformationMethod = PasswordTransformationMethod.getInstance() + if (entry.extraContentWithoutAuthData.isNotEmpty()) { + extraContentContainer.visibility = View.VISIBLE + extraContent.typeface = monoTypeface + extraContent.setText(entry.extraContentWithoutAuthData) + if (!showExtraContent) { + extraContent.transformationMethod = PasswordTransformationMethod.getInstance() + } + extraContentContainer.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutAuthData) } + extraContent.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutAuthData) } } - extraContentContainer.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutUsername) } - extraContent.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutUsername) } if (entry.hasUsername()) { usernameText.typeface = monoTypeface @@ -180,10 +190,29 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { } else { usernameTextContainer.visibility = View.GONE } + + if (entry.hasTotp()) { + otpTextContainer.visibility = View.VISIBLE + otpTextContainer.setEndIconOnClickListener { + copyTextToClipboard( + otpText.text.toString(), + snackbarTextRes = R.string.clipboard_otp_copied_text + ) + } + launch(Dispatchers.IO) { + repeat(Int.MAX_VALUE) { + val code = entry.calculateTotpCode() ?: "Error" + withContext(Dispatchers.Main) { + otpText.setText(code) + } + delay(30.seconds) + } + } + } } } - if (settings.getBoolean("copy_on_decrypt", true)) { + if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, true)) { copyPasswordToClipboard(entry.password) } } catch (e: Exception) { diff --git a/app/src/main/java/com/zeapo/pwdstore/crypto/GetKeyIdsActivity.kt b/app/src/main/java/com/zeapo/pwdstore/crypto/GetKeyIdsActivity.kt index 94d5b68c..97f6bab2 100644 --- a/app/src/main/java/com/zeapo/pwdstore/crypto/GetKeyIdsActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/crypto/GetKeyIdsActivity.kt @@ -14,6 +14,7 @@ import androidx.core.content.edit import androidx.lifecycle.lifecycleScope import com.github.ajalt.timberkt.Timber import com.github.ajalt.timberkt.e +import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.snackbar import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -69,7 +70,7 @@ class GetKeyIdsActivity : BasePgpActivity() { ?: LongArray(0) val keys = ids.map { it.toString() }.toSet() // use Long - settings.edit { putStringSet("openpgp_key_ids_set", keys) } + settings.edit { putStringSet(PreferenceKeys.OPENPGP_KEY_IDS_SET, keys) } snackbar(message = "PGP keys selected") setResult(RESULT_OK) finish() diff --git a/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt b/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt index 67bf9926..13f9add3 100644 --- a/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt @@ -17,16 +17,19 @@ import androidx.core.widget.doOnTextChanged import androidx.lifecycle.lifecycleScope import com.github.ajalt.timberkt.e import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zeapo.pwdstore.PasswordEntry -import com.zeapo.pwdstore.utils.isInsideRepository +import com.google.zxing.integration.android.IntentIntegrator +import com.google.zxing.integration.android.IntentIntegrator.QR_CODE import com.zeapo.pwdstore.R import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure import com.zeapo.pwdstore.databinding.PasswordCreationActivityBinding +import com.zeapo.pwdstore.model.PasswordEntry import com.zeapo.pwdstore.ui.dialogs.PasswordGeneratorDialogFragment import com.zeapo.pwdstore.ui.dialogs.XkPasswordGeneratorDialogFragment import com.zeapo.pwdstore.utils.PasswordRepository +import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.commitChange +import com.zeapo.pwdstore.utils.isInsideRepository import com.zeapo.pwdstore.utils.snackbar import com.zeapo.pwdstore.utils.viewBinding import kotlinx.coroutines.Dispatchers @@ -62,6 +65,29 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB with(binding) { setContentView(root) generatePassword.setOnClickListener { generatePassword() } + otpImportButton.setOnClickListener { + registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + otpImportButton.isVisible = false + val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data) + val contents = "${intentResult.contents}\n" + val currentExtras = extraContent.text.toString() + if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') + extraContent.append("\n$contents") + else + extraContent.append(contents) + snackbar(message = getString(R.string.otp_import_success)) + } else { + snackbar(message = getString(R.string.otp_import_failure)) + } + }.launch( + IntentIntegrator(this@PasswordCreationActivity) + .setOrientationLocked(false) + .setBeepEnabled(false) + .setDesiredBarcodeFormats(QR_CODE) + .createScanIntent() + ) + } category.apply { if (suggestedName != null || suggestedPass != null || shouldGeneratePassword) { @@ -95,7 +121,7 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB val username = filename.text.toString() val extras = "username:$username\n${extraContent.text}" - filename.setText("") + filename.text?.clear() extraContent.setText(extras) } else { // User wants to disable username encryption, so we extract the @@ -104,20 +130,20 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB val username = entry.username // username should not be null here by the logic in - // updateEncryptUsernameState, but it could still happen due to + // updateViewState, but it could still happen due to // input lag. if (username != null) { filename.setText(username) - extraContent.setText(entry.extraContentWithoutUsername) + extraContent.setText(entry.extraContentWithoutAuthData) } } - updateEncryptUsernameState() + updateViewState() } } listOf(filename, extraContent).forEach { - it.doOnTextChanged { _, _, _, _ -> updateEncryptUsernameState() } + it.doOnTextChanged { _, _, _, _ -> updateViewState() } } - updateEncryptUsernameState() + updateViewState() } suggestedPass?.let { password.setText(it) @@ -150,7 +176,7 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB } private fun generatePassword() { - when (settings.getString("pref_key_pwgen_type", KEY_PWGEN_TYPE_CLASSIC)) { + when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE, KEY_PWGEN_TYPE_CLASSIC)) { KEY_PWGEN_TYPE_CLASSIC -> PasswordGeneratorDialogFragment() .show(supportFragmentManager, "generator") KEY_PWGEN_TYPE_XKPASSWD -> XkPasswordGeneratorDialogFragment() @@ -158,17 +184,18 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB } } - private fun updateEncryptUsernameState() = with(binding) { + private fun updateViewState() = with(binding) { + // Use PasswordEntry to parse extras for username + val entry = PasswordEntry("PLACEHOLDER\n${extraContent.text}") encryptUsername.apply { if (visibility != View.VISIBLE) return@with val hasUsernameInFileName = filename.text.toString().isNotBlank() - // Use PasswordEntry to parse extras for username - val entry = PasswordEntry("PLACEHOLDER\n${extraContent.text}") val hasUsernameInExtras = entry.hasUsername() isEnabled = hasUsernameInFileName xor hasUsernameInExtras isChecked = hasUsernameInExtras } + otpImportButton.isVisible = !entry.hasTotp() } /** diff --git a/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt index c75f7ad3..ae2bbc07 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt @@ -22,6 +22,7 @@ import com.zeapo.pwdstore.git.config.ConnectionMode import com.zeapo.pwdstore.git.config.Protocol import com.zeapo.pwdstore.git.config.SshApiSessionFactory import com.zeapo.pwdstore.utils.PasswordRepository +import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.getEncryptedPrefs import java.io.File import java.net.URI @@ -53,14 +54,14 @@ abstract class BaseGitActivity : AppCompatActivity() { settings = PreferenceManager.getDefaultSharedPreferences(this) encryptedSettings = getEncryptedPrefs("git_operation") - protocol = Protocol.fromString(settings.getString("git_remote_protocol", null)) - connectionMode = ConnectionMode.fromString(settings.getString("git_remote_auth", null)) - serverHostname = settings.getString("git_remote_server", null) ?: "" - serverPort = settings.getString("git_remote_port", null) ?: "" - serverUser = settings.getString("git_remote_username", null) ?: "" - serverPath = settings.getString("git_remote_location", null) ?: "" - username = settings.getString("git_config_user_name", null) ?: "" - email = settings.getString("git_config_user_email", null) ?: "" + protocol = Protocol.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_PROTOCOL, null)) + connectionMode = ConnectionMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH, null)) + serverHostname = settings.getString(PreferenceKeys.GIT_REMOTE_SERVER, null) ?: "" + serverPort = settings.getString(PreferenceKeys.GIT_REMOTE_PORT, null) ?: "" + serverUser = settings.getString(PreferenceKeys.GIT_REMOTE_USERNAME, null) ?: "" + serverPath = settings.getString(PreferenceKeys.GIT_REMOTE_LOCATION, null) ?: "" + username = settings.getString(PreferenceKeys.GIT_CONFIG_USER_NAME, null) ?: "" + email = settings.getString(PreferenceKeys.GIT_CONFIG_USER_EMAIL, null) ?: "" updateUrl() } @@ -148,7 +149,7 @@ abstract class BaseGitActivity : AppCompatActivity() { PasswordRepository.addRemote("origin", newUrl, true) // When the server changes, remote password and host key file should be deleted. if (previousUrl.isNotEmpty() && newUrl != previousUrl) { - encryptedSettings.edit { remove("https_password") } + encryptedSettings.edit { remove(PreferenceKeys.HTTPS_PASSWORD) } File("$filesDir/.host_key").delete() } url = newUrl diff --git a/app/src/main/java/com/zeapo/pwdstore/git/BreakOutOfDetached.kt b/app/src/main/java/com/zeapo/pwdstore/git/BreakOutOfDetached.kt index 31c376eb..8533187f 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/BreakOutOfDetached.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/BreakOutOfDetached.kt @@ -58,7 +58,7 @@ class BreakOutOfDetached(fileDir: File, callingActivity: Activity) : GitOperatio } } } - GitAsyncTask(callingActivity, true, this, null) + GitAsyncTask(callingActivity, this, null) .execute(*this.commands.toTypedArray()) } diff --git a/app/src/main/java/com/zeapo/pwdstore/git/CloneOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/CloneOperation.kt index 705566be..0e89af0f 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/CloneOperation.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/CloneOperation.kt @@ -36,7 +36,7 @@ class CloneOperation(fileDir: File, callingActivity: Activity) : GitOperation(fi override fun execute() { (this.command as? CloneCommand)?.setCredentialsProvider(this.provider) - GitAsyncTask(callingActivity, false, this, Intent()).execute(this.command) + GitAsyncTask(callingActivity, this, Intent()).execute(this.command) } override fun onError(err: Exception) { diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.kt index a2b4e2a8..84e08dfc 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.kt @@ -10,7 +10,6 @@ import android.content.Context import android.content.Intent import android.os.AsyncTask import com.github.ajalt.timberkt.e -import com.zeapo.pwdstore.PasswordStore import com.zeapo.pwdstore.R import com.zeapo.pwdstore.git.config.SshjSessionFactory import net.schmizz.sshj.common.DisconnectReason @@ -30,7 +29,6 @@ import java.lang.ref.WeakReference class GitAsyncTask( activity: Activity, - private val refreshListOnEnd: Boolean, private val operation: GitOperation, private val finishWithResultOnEnd: Intent?, private val silentlyExecute: Boolean = false @@ -170,9 +168,6 @@ class GitAsyncTask( } } } - if (refreshListOnEnd) { - (activity as? PasswordStore)?.resetPasswordList() - } (SshSessionFactory.getInstance() as? SshjSessionFactory)?.clearCredentials() SshSessionFactory.setInstance(null) } diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt index be911a3a..35cbc68a 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt @@ -14,6 +14,7 @@ import com.google.android.material.snackbar.Snackbar import com.zeapo.pwdstore.R import com.zeapo.pwdstore.databinding.ActivityGitConfigBinding import com.zeapo.pwdstore.utils.PasswordRepository +import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.viewBinding import org.eclipse.jgit.lib.Constants @@ -58,8 +59,8 @@ class GitConfigActivity : BaseGitActivity() { .show() } else { settings.edit { - putString("git_config_user_email", email) - putString("git_config_user_name", name) + putString(PreferenceKeys.GIT_CONFIG_USER_EMAIL, email) + putString(PreferenceKeys.GIT_CONFIG_USER_NAME, name) } PasswordRepository.setUserName(name) PasswordRepository.setUserEmail(email) diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitOperation.kt index 8cf09b39..48c28920 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitOperation.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/GitOperation.kt @@ -9,6 +9,7 @@ import android.app.Activity import android.content.Intent import android.view.LayoutInflater import androidx.annotation.StringRes +import androidx.core.content.ContextCompat import androidx.core.content.edit import androidx.preference.PreferenceManager import com.google.android.material.checkbox.MaterialCheckBox @@ -22,6 +23,7 @@ import com.zeapo.pwdstore.git.config.SshApiSessionFactory import com.zeapo.pwdstore.git.config.SshAuthData import com.zeapo.pwdstore.git.config.SshjSessionFactory import com.zeapo.pwdstore.utils.PasswordRepository +import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.getEncryptedPrefs import com.zeapo.pwdstore.utils.requestInputFocusOnView import net.schmizz.sshj.userauth.password.PasswordFinder @@ -35,6 +37,7 @@ import org.eclipse.jgit.transport.URIish import java.io.File import kotlin.coroutines.Continuation import kotlin.coroutines.resume +import com.google.android.material.R as materialR private class GitOperationCredentialFinder(val callingActivity: Activity, val connectionMode: ConnectionMode) : InteractivePasswordFinder() { @@ -48,7 +51,7 @@ private class GitOperationCredentialFinder(val callingActivity: Activity, val co @StringRes val errorRes: Int when (connectionMode) { ConnectionMode.SshKey -> { - credentialPref = "ssh_key_local_passphrase" + credentialPref = PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE messageRes = R.string.passphrase_dialog_text hintRes = R.string.ssh_keygen_passphrase rememberRes = R.string.git_operation_remember_passphrase @@ -56,7 +59,7 @@ private class GitOperationCredentialFinder(val callingActivity: Activity, val co } ConnectionMode.Password -> { // Could be either an SSH or an HTTPS password - credentialPref = "https_password" + credentialPref = PreferenceKeys.HTTPS_PASSWORD messageRes = R.string.password_dialog_text hintRes = R.string.git_operation_hint_password rememberRes = R.string.git_operation_remember_password @@ -77,7 +80,8 @@ private class GitOperationCredentialFinder(val callingActivity: Activity, val co val rememberCredential = dialogView.findViewById<MaterialCheckBox>(R.id.git_auth_remember_credential) rememberCredential.setText(rememberRes) if (isRetry) - editCredential.error = callingActivity.resources.getString(errorRes) + editCredential.setError(callingActivity.resources.getString(errorRes), + ContextCompat.getDrawable(callingActivity, materialR.drawable.mtrl_ic_error)) MaterialAlertDialogBuilder(callingActivity).run { setTitle(R.string.passphrase_dialog_title) setMessage(messageRes) @@ -219,14 +223,14 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: Activity when (SshSessionFactory.getInstance()) { is SshApiSessionFactory -> { PreferenceManager.getDefaultSharedPreferences(callingActivity.applicationContext) - .edit { remove("ssh_openkeystore_keyid") } + .edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) } } is SshjSessionFactory -> { callingActivity.applicationContext .getEncryptedPrefs("git_operation") .edit { - remove("ssh_key_local_passphrase") - remove("https_password") + remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) + remove(PreferenceKeys.HTTPS_PASSWORD) } } } diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt index 7a3978ba..10f44960 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt @@ -17,6 +17,7 @@ import com.zeapo.pwdstore.databinding.ActivityGitCloneBinding import com.zeapo.pwdstore.git.config.ConnectionMode import com.zeapo.pwdstore.git.config.Protocol import com.zeapo.pwdstore.utils.PasswordRepository +import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.viewBinding import java.io.IOException @@ -107,12 +108,12 @@ class GitServerConfigActivity : BaseGitActivity() { when (val result = updateUrl()) { GitUpdateUrlResult.Ok -> { settings.edit { - putString("git_remote_protocol", protocol.pref) - putString("git_remote_auth", connectionMode.pref) - putString("git_remote_server", serverHostname) - putString("git_remote_port", serverPort) - putString("git_remote_username", serverUser) - putString("git_remote_location", serverPath) + putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, protocol.pref) + putString(PreferenceKeys.GIT_REMOTE_AUTH, connectionMode.pref) + putString(PreferenceKeys.GIT_REMOTE_SERVER, serverHostname) + putString(PreferenceKeys.GIT_REMOTE_PORT, serverPort) + putString(PreferenceKeys.GIT_REMOTE_USERNAME, serverUser) + putString(PreferenceKeys.GIT_REMOTE_LOCATION, serverPath) } if (!isClone) { Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show() diff --git a/app/src/main/java/com/zeapo/pwdstore/git/PullOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/PullOperation.kt index 2d5f6bbe..c543f885 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/PullOperation.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/PullOperation.kt @@ -35,7 +35,7 @@ class PullOperation(fileDir: File, callingActivity: Activity) : GitOperation(fil override fun execute() { (this.command as? PullCommand)?.setCredentialsProvider(this.provider) - GitAsyncTask(callingActivity, false, this, Intent()).execute(this.command) + GitAsyncTask(callingActivity, this, Intent()).execute(this.command) } override fun onError(err: Exception) { diff --git a/app/src/main/java/com/zeapo/pwdstore/git/PushOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/PushOperation.kt index 52e6e537..fc58705f 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/PushOperation.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/PushOperation.kt @@ -35,7 +35,7 @@ class PushOperation(fileDir: File, callingActivity: Activity) : GitOperation(fil override fun execute() { (this.command as? PushCommand)?.setCredentialsProvider(this.provider) - GitAsyncTask(callingActivity, false, this, Intent()).execute(this.command) + GitAsyncTask(callingActivity, this, Intent()).execute(this.command) } override fun onError(err: Exception) { diff --git a/app/src/main/java/com/zeapo/pwdstore/git/ResetToRemoteOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/ResetToRemoteOperation.kt index c652889d..be9e929f 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/ResetToRemoteOperation.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/ResetToRemoteOperation.kt @@ -41,7 +41,7 @@ class ResetToRemoteOperation(fileDir: File, callingActivity: Activity) : GitOper override fun execute() { this.fetchCommand?.setCredentialsProvider(this.provider) - GitAsyncTask(callingActivity, false, this, Intent()) + GitAsyncTask(callingActivity, this, Intent()) .execute(this.addCommand, this.fetchCommand, this.resetCommand) } diff --git a/app/src/main/java/com/zeapo/pwdstore/git/SyncOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/SyncOperation.kt index 1f241c64..3af6f08d 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/SyncOperation.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/SyncOperation.kt @@ -50,7 +50,7 @@ class SyncOperation(fileDir: File, callingActivity: Activity) : GitOperation(fil this.pullCommand?.setCredentialsProvider(this.provider) this.pushCommand?.setCredentialsProvider(this.provider) } - GitAsyncTask(callingActivity, false, this, Intent()).execute(this.addCommand, this.statusCommand, this.commitCommand, this.pullCommand, this.pushCommand) + GitAsyncTask(callingActivity, this, Intent()).execute(this.addCommand, this.statusCommand, this.commitCommand, this.pullCommand, this.pushCommand) } override fun onError(err: Exception) { diff --git a/app/src/main/java/com/zeapo/pwdstore/git/config/SshApiSessionFactory.java b/app/src/main/java/com/zeapo/pwdstore/git/config/SshApiSessionFactory.java index e5a5fd17..5ad12ef8 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/config/SshApiSessionFactory.java +++ b/app/src/main/java/com/zeapo/pwdstore/git/config/SshApiSessionFactory.java @@ -21,6 +21,7 @@ import com.jcraft.jsch.Session; import com.jcraft.jsch.UserInfo; import com.zeapo.pwdstore.R; import com.zeapo.pwdstore.git.BaseGitActivity; +import com.zeapo.pwdstore.utils.PreferenceKeys; import org.eclipse.jgit.errors.UnsupportedCredentialItem; import org.eclipse.jgit.transport.CredentialItem; @@ -51,8 +52,8 @@ public class SshApiSessionFactory extends JschConfigSessionFactory { */ public static final int POST_SIGNATURE = 301; - private String username; - private Identity identity; + private final String username; + private final Identity identity; public SshApiSessionFactory(String username, Identity identity) { this.username = username; @@ -108,12 +109,12 @@ public class SshApiSessionFactory extends JschConfigSessionFactory { * build. */ public static class IdentityBuilder { - private SshAuthenticationConnection connection; + private final SshAuthenticationConnection connection; private SshAuthenticationApi api; private String keyId, description, alg; private byte[] publicKey; - private BaseGitActivity callingActivity; - private SharedPreferences settings; + private final BaseGitActivity callingActivity; + private final SharedPreferences settings; /** * Construct a new IdentityBuilder @@ -137,7 +138,7 @@ public class SshApiSessionFactory extends JschConfigSessionFactory { settings = PreferenceManager.getDefaultSharedPreferences( callingActivity.getApplicationContext()); - keyId = settings.getString("ssh_openkeystore_keyid", null); + keyId = settings.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null); } /** @@ -163,7 +164,7 @@ public class SshApiSessionFactory extends JschConfigSessionFactory { SshAuthenticationApiError error = result.getParcelableExtra(SshAuthenticationApi.EXTRA_ERROR); // On an OpenKeychain SSH API error, clear out the stored keyid - settings.edit().putString("ssh_openkeystore_keyid", null).apply(); + settings.edit().putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null).apply(); switch (error.getError()) { // If the problem was just a bad keyid, reset to allow them to choose a @@ -214,7 +215,7 @@ public class SshApiSessionFactory extends JschConfigSessionFactory { if (intent.hasExtra(SshAuthenticationApi.EXTRA_KEY_ID)) { keyId = intent.getStringExtra(SshAuthenticationApi.EXTRA_KEY_ID); description = intent.getStringExtra(SshAuthenticationApi.EXTRA_KEY_DESCRIPTION); - settings.edit().putString("ssh_openkeystore_keyid", keyId).apply(); + settings.edit().putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, keyId).apply(); } if (intent.hasExtra(SshAuthenticationApi.EXTRA_SSH_PUBLIC_KEY)) { @@ -284,10 +285,12 @@ public class SshApiSessionFactory extends JschConfigSessionFactory { * A Jsch identity that delegates key operations via the OpenKeychain SSH API */ public static class ApiIdentity implements Identity { - private String keyId, description, alg; - private byte[] publicKey; - private Activity callingActivity; - private SshAuthenticationApi api; + private final String keyId; + private final String description; + private final String alg; + private final byte[] publicKey; + private final Activity callingActivity; + private final SshAuthenticationApi api; private CountDownLatch latch; private byte[] signature; diff --git a/app/src/main/java/com/zeapo/pwdstore/git/config/SshjConfig.kt b/app/src/main/java/com/zeapo/pwdstore/git/config/SshjConfig.kt new file mode 100644 index 00000000..3e67eba7 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/config/SshjConfig.kt @@ -0,0 +1,271 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.git.config + +import com.github.ajalt.timberkt.Timber +import com.github.ajalt.timberkt.d +import com.hierynomus.sshj.signature.SignatureEdDSA +import com.hierynomus.sshj.transport.cipher.BlockCiphers +import com.hierynomus.sshj.transport.mac.Macs +import com.hierynomus.sshj.userauth.keyprovider.OpenSSHKeyV1KeyFile +import net.schmizz.keepalive.KeepAliveProvider +import net.schmizz.sshj.ConfigImpl +import net.schmizz.sshj.common.LoggerFactory +import net.schmizz.sshj.signature.SignatureECDSA +import net.schmizz.sshj.signature.SignatureRSA +import net.schmizz.sshj.signature.SignatureRSA.FactoryCERT +import net.schmizz.sshj.transport.compression.NoneCompression +import net.schmizz.sshj.transport.kex.Curve25519SHA256 +import net.schmizz.sshj.transport.kex.Curve25519SHA256.FactoryLibSsh +import net.schmizz.sshj.transport.kex.DHGexSHA256 +import net.schmizz.sshj.transport.kex.ECDHNistP +import net.schmizz.sshj.transport.random.JCERandom +import net.schmizz.sshj.transport.random.SingletonRandomFactory +import net.schmizz.sshj.userauth.keyprovider.OpenSSHKeyFile +import net.schmizz.sshj.userauth.keyprovider.PKCS5KeyFile +import net.schmizz.sshj.userauth.keyprovider.PKCS8KeyFile +import net.schmizz.sshj.userauth.keyprovider.PuTTYKeyFile +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.slf4j.Logger +import org.slf4j.Marker +import java.security.Security + + +fun setUpBouncyCastleForSshj() { + // Replace the Android BC provider with the Java BouncyCastle provider since the former does + // not include all the required algorithms. + // Note: This may affect crypto operations in other parts of the application. + val bcIndex = Security.getProviders().indexOfFirst { + it.name == BouncyCastleProvider.PROVIDER_NAME + } + if (bcIndex == -1) { + // No Android BC found, install Java BC at lowest priority. + Security.addProvider(BouncyCastleProvider()) + } else { + // Replace Android BC with Java BC, inserted at the same position. + Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) + // May be needed on Android Pie+ as per https://stackoverflow.com/a/57897224/297261 + try { + Class.forName("sun.security.jca.Providers") + } catch (e: ClassNotFoundException) { + } + Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1) + } + d { "JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}" } +} + +private abstract class AbstractLogger(private val name: String) : Logger { + + abstract fun t(message: String, t: Throwable? = null, vararg args: Any?) + abstract fun d(message: String, t: Throwable? = null, vararg args: Any?) + abstract fun i(message: String, t: Throwable? = null, vararg args: Any?) + abstract fun w(message: String, t: Throwable? = null, vararg args: Any?) + abstract fun e(message: String, t: Throwable? = null, vararg args: Any?) + + override fun getName() = name + + override fun isTraceEnabled(marker: Marker?): Boolean = isTraceEnabled + override fun isDebugEnabled(marker: Marker?): Boolean = isDebugEnabled + override fun isInfoEnabled(marker: Marker?): Boolean = isInfoEnabled + override fun isWarnEnabled(marker: Marker?): Boolean = isWarnEnabled + override fun isErrorEnabled(marker: Marker?): Boolean = isErrorEnabled + + override fun trace(msg: String) = t(msg) + override fun trace(format: String, arg: Any?) = t(format, null, arg) + override fun trace(format: String, arg1: Any?, arg2: Any?) = t(format, null, arg1, arg2) + override fun trace(format: String, vararg arguments: Any?) = t(format, null, *arguments) + override fun trace(msg: String, t: Throwable?) = t(msg, t) + override fun trace(marker: Marker, msg: String) = trace(msg) + override fun trace(marker: Marker?, format: String, arg: Any?) = trace(format, arg) + override fun trace(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = + trace(format, arg1, arg2) + + override fun trace(marker: Marker?, format: String, vararg arguments: Any?) = + trace(format, *arguments) + + override fun trace(marker: Marker?, msg: String, t: Throwable?) = trace(msg, t) + + override fun debug(msg: String) = d(msg) + override fun debug(format: String, arg: Any?) = d(format, null, arg) + override fun debug(format: String, arg1: Any?, arg2: Any?) = d(format, null, arg1, arg2) + override fun debug(format: String, vararg arguments: Any?) = d(format, null, *arguments) + override fun debug(msg: String, t: Throwable?) = d(msg, t) + override fun debug(marker: Marker, msg: String) = debug(msg) + override fun debug(marker: Marker?, format: String, arg: Any?) = debug(format, arg) + override fun debug(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = + debug(format, arg1, arg2) + + override fun debug(marker: Marker?, format: String, vararg arguments: Any?) = + debug(format, *arguments) + + override fun debug(marker: Marker?, msg: String, t: Throwable?) = debug(msg, t) + + override fun info(msg: String) = i(msg) + override fun info(format: String, arg: Any?) = i(format, null, arg) + override fun info(format: String, arg1: Any?, arg2: Any?) = i(format, null, arg1, arg2) + override fun info(format: String, vararg arguments: Any?) = i(format, null, *arguments) + override fun info(msg: String, t: Throwable?) = i(msg, t) + override fun info(marker: Marker, msg: String) = info(msg) + override fun info(marker: Marker?, format: String, arg: Any?) = info(format, arg) + override fun info(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = + info(format, arg1, arg2) + + override fun info(marker: Marker?, format: String, vararg arguments: Any?) = + info(format, *arguments) + + override fun info(marker: Marker?, msg: String, t: Throwable?) = info(msg, t) + + override fun warn(msg: String) = w(msg) + override fun warn(format: String, arg: Any?) = w(format, null, arg) + override fun warn(format: String, arg1: Any?, arg2: Any?) = w(format, null, arg1, arg2) + override fun warn(format: String, vararg arguments: Any?) = w(format, null, *arguments) + override fun warn(msg: String, t: Throwable?) = w(msg, t) + override fun warn(marker: Marker, msg: String) = warn(msg) + override fun warn(marker: Marker?, format: String, arg: Any?) = warn(format, arg) + override fun warn(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = + warn(format, arg1, arg2) + + override fun warn(marker: Marker?, format: String, vararg arguments: Any?) = + warn(format, *arguments) + + override fun warn(marker: Marker?, msg: String, t: Throwable?) = warn(msg, t) + + override fun error(msg: String) = e(msg) + override fun error(format: String, arg: Any?) = e(format, null, arg) + override fun error(format: String, arg1: Any?, arg2: Any?) = e(format, null, arg1, arg2) + override fun error(format: String, vararg arguments: Any?) = e(format, null, *arguments) + override fun error(msg: String, t: Throwable?) = e(msg, t) + override fun error(marker: Marker, msg: String) = error(msg) + override fun error(marker: Marker?, format: String, arg: Any?) = error(format, arg) + override fun error(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = + error(format, arg1, arg2) + + override fun error(marker: Marker?, format: String, vararg arguments: Any?) = + error(format, *arguments) + + override fun error(marker: Marker?, msg: String, t: Throwable?) = error(msg, t) +} + +object TimberLoggerFactory : LoggerFactory { + private class TimberLogger(name: String) : AbstractLogger(name) { + + // We defer the log level checks to Timber. + override fun isTraceEnabled() = true + override fun isDebugEnabled() = true + override fun isInfoEnabled() = true + override fun isWarnEnabled() = true + override fun isErrorEnabled() = true + + // Replace slf4j's "{}" format string style with standard Java's "%s". + // The supposedly redundant escape on the } is not redundant. + @Suppress("RegExpRedundantEscape") + private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s") + + override fun t(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).v(t, message.fix(), *args) + } + + override fun d(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).d(t, message.fix(), *args) + } + + override fun i(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).i(t, message.fix(), *args) + } + + override fun w(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).w(t, message.fix(), *args) + } + + override fun e(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).e(t, message.fix(), *args) + } + } + + override fun getLogger(name: String): Logger { + return TimberLogger(name) + } + + override fun getLogger(clazz: Class<*>): Logger { + return TimberLogger(clazz.name) + } + +} + +class SshjConfig : ConfigImpl() { + + init { + loggerFactory = TimberLoggerFactory + keepAliveProvider = KeepAliveProvider.HEARTBEAT + + initKeyExchangeFactories() + initSignatureFactories() + initRandomFactory() + initFileKeyProviderFactories() + initCipherFactories() + initCompressionFactories() + initMACFactories() + } + + private fun initKeyExchangeFactories() { + keyExchangeFactories = listOf( + Curve25519SHA256.Factory(), + FactoryLibSsh(), + ECDHNistP.Factory521(), + ECDHNistP.Factory384(), + ECDHNistP.Factory256(), + DHGexSHA256.Factory() + ) + } + + private fun initSignatureFactories() { + signatureFactories = listOf( + SignatureEdDSA.Factory(), + SignatureECDSA.Factory256(), + SignatureECDSA.Factory384(), + SignatureECDSA.Factory521(), + SignatureRSA.Factory(), + FactoryCERT() + ) + } + + private fun initRandomFactory() { + randomFactory = SingletonRandomFactory(JCERandom.Factory()) + } + + private fun initFileKeyProviderFactories() { + fileKeyProviderFactories = listOf( + OpenSSHKeyV1KeyFile.Factory(), + PKCS8KeyFile.Factory(), + PKCS5KeyFile.Factory(), + OpenSSHKeyFile.Factory(), + PuTTYKeyFile.Factory() + ) + } + + + private fun initCipherFactories() { + cipherFactories = listOf( + BlockCiphers.AES128CTR(), + BlockCiphers.AES192CTR(), + BlockCiphers.AES256CTR() + ) + } + + private fun initMACFactories() { + macFactories = listOf( + Macs.HMACSHA2256(), + Macs.HMACSHA2256Etm(), + Macs.HMACSHA2512(), + Macs.HMACSHA2512Etm() + ) + } + + private fun initCompressionFactories() { + compressionFactories = listOf( + NoneCompression.Factory() + ) + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/config/SshjSessionFactory.kt b/app/src/main/java/com/zeapo/pwdstore/git/config/SshjSessionFactory.kt index f900e959..45e7fe3e 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/config/SshjSessionFactory.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/config/SshjSessionFactory.kt @@ -132,7 +132,7 @@ private class SshjSession(private val uri: URIish, private val username: String, private var currentCommand: Session? = null fun connect(): SshjSession { - ssh = SSHClient() + ssh = SSHClient(SshjConfig()) ssh.addHostKeyVerifier(makeTofuHostKeyVerifier(hostKeyFile)) ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22) if (!ssh.isConnected) diff --git a/app/src/main/java/com/zeapo/pwdstore/model/PasswordEntry.kt b/app/src/main/java/com/zeapo/pwdstore/model/PasswordEntry.kt new file mode 100644 index 00000000..27a0c584 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/model/PasswordEntry.kt @@ -0,0 +1,121 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.model + +import com.zeapo.pwdstore.utils.Otp +import com.zeapo.pwdstore.utils.TotpFinder +import com.zeapo.pwdstore.utils.UriTotpFinder +import java.io.ByteArrayOutputStream +import java.io.UnsupportedEncodingException +import java.util.Date + +/** + * A single entry in password store. [totpFinder] is an implementation of [TotpFinder] that let's us + * abstract out the Android-specific part and continue testing the class in the JVM. + */ +class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTotpFinder()) { + + val password: String + val username: String? + val digits: String + val totpSecret: String? + val totpPeriod: Long + val totpAlgorithm: String + var extraContent: String + private set + + @Throws(UnsupportedEncodingException::class) + constructor(os: ByteArrayOutputStream) : this(os.toString("UTF-8"), UriTotpFinder()) + + init { + val passContent = content.split("\n".toRegex(), 2).toTypedArray() + password = passContent[0] + extraContent = findExtraContent(passContent) + username = findUsername() + digits = findOtpDigits(content) + totpSecret = findTotpSecret(content) + totpPeriod = findTotpPeriod(content) + totpAlgorithm = findTotpAlgorithm(content) + } + + fun hasExtraContent(): Boolean { + return extraContent.isNotEmpty() + } + + fun hasTotp(): Boolean { + return totpSecret != null + } + + fun hasUsername(): Boolean { + return username != null + } + + fun calculateTotpCode(): String? { + if (totpSecret == null) + return null + return Otp.calculateCode(totpSecret, Date().time / (1000 * totpPeriod), totpAlgorithm, digits) + } + + val extraContentWithoutAuthData by lazy { + extraContent.splitToSequence("\n").filter { line -> + return@filter when { + USERNAME_FIELDS.any { prefix -> line.startsWith(prefix, ignoreCase = true) } -> { + false + } + line.startsWith("otpauth://", ignoreCase = true) || + line.startsWith("totp:", ignoreCase = true) -> { + false + } + else -> { + true + } + } + }.joinToString(separator = "\n") + } + + private fun findUsername(): String? { + extraContent.splitToSequence("\n").forEach { line -> + for (prefix in USERNAME_FIELDS) { + if (line.startsWith(prefix, ignoreCase = true)) + return line.substring(prefix.length).trimStart() + } + } + return null + } + + private fun findExtraContent(passContent: Array<String>): String { + return if (passContent.size > 1) passContent[1] else "" + } + + private fun findTotpSecret(decryptedContent: String): String? { + return totpFinder.findSecret(decryptedContent) + } + + private fun findOtpDigits(decryptedContent: String): String { + return totpFinder.findDigits(decryptedContent) + } + + private fun findTotpPeriod(decryptedContent: String): Long { + return totpFinder.findPeriod(decryptedContent) + } + + private fun findTotpAlgorithm(decryptedContent: String): String { + return totpFinder.findAlgorithm(decryptedContent) + } + + companion object { + val USERNAME_FIELDS = arrayOf( + "login:", + "username:", + "user:", + "account:", + "email:", + "name:", + "handle:", + "id:", + "identity:" + ) + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/pwgen/PasswordGenerator.kt b/app/src/main/java/com/zeapo/pwdstore/pwgen/PasswordGenerator.kt index b4accec7..38c82d4a 100644 --- a/app/src/main/java/com/zeapo/pwdstore/pwgen/PasswordGenerator.kt +++ b/app/src/main/java/com/zeapo/pwdstore/pwgen/PasswordGenerator.kt @@ -7,6 +7,7 @@ package com.zeapo.pwdstore.pwgen import android.content.Context import androidx.core.content.edit import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.clearFlag import com.zeapo.pwdstore.utils.hasFlag @@ -102,7 +103,7 @@ object PasswordGenerator { } } - val length = prefs.getInt("length", DEFAULT_LENGTH) + 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)) } diff --git a/app/src/main/java/com/zeapo/pwdstore/pwgenxkpwd/XkpwdDictionary.kt b/app/src/main/java/com/zeapo/pwdstore/pwgenxkpwd/XkpwdDictionary.kt index 92438ed0..37878b70 100644 --- a/app/src/main/java/com/zeapo/pwdstore/pwgenxkpwd/XkpwdDictionary.kt +++ b/app/src/main/java/com/zeapo/pwdstore/pwgenxkpwd/XkpwdDictionary.kt @@ -7,6 +7,7 @@ package com.zeapo.pwdstore.pwgenxkpwd import android.content.Context import androidx.preference.PreferenceManager import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.utils.PreferenceKeys import java.io.File class XkpwdDictionary(context: Context) { @@ -14,10 +15,10 @@ class XkpwdDictionary(context: Context) { init { val prefs = PreferenceManager.getDefaultSharedPreferences(context) - val uri = prefs.getString("pref_key_custom_dict", "")!! + val uri = prefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT, "")!! val customDictFile = File(context.filesDir, XKPWD_CUSTOM_DICT_FILE) - val lines = if (prefs.getBoolean("pref_key_is_custom_dict", false) && + val lines = if (prefs.getBoolean(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT, false) && uri.isNotEmpty() && customDictFile.canRead()) { customDictFile.readLines() } else { diff --git a/app/src/main/java/com/zeapo/pwdstore/sshkeygen/ShowSshKeyFragment.kt b/app/src/main/java/com/zeapo/pwdstore/sshkeygen/ShowSshKeyFragment.kt index 8f4fbf84..109ebd01 100644 --- a/app/src/main/java/com/zeapo/pwdstore/sshkeygen/ShowSshKeyFragment.kt +++ b/app/src/main/java/com/zeapo/pwdstore/sshkeygen/ShowSshKeyFragment.kt @@ -36,7 +36,7 @@ class ShowSshKeyFragment : DialogFragment() { createMaterialDialog(view) val ad = builder.create() ad.setOnShowListener { - val b = ad.getButton(AlertDialog.BUTTON_NEUTRAL) + val b = ad.getButton(AlertDialog.BUTTON_POSITIVE) b.setOnClickListener { val clipboard = activity.clipboard ?: return@setOnClickListener val clip = ClipData.newPlainText("public key", publicKey.text.toString()) @@ -49,9 +49,8 @@ class ShowSshKeyFragment : DialogFragment() { private fun createMaterialDialog(view: View) { builder.setView(view) builder.setTitle(getString(R.string.your_public_key)) - builder.setPositiveButton(getString(R.string.dialog_ok)) { _, _ -> requireActivity().finish() } - builder.setNegativeButton(getString(R.string.dialog_cancel), null) - builder.setNeutralButton(resources.getString(R.string.ssh_keygen_copy), null) + builder.setNegativeButton(R.string.dialog_ok) { _, _ -> requireActivity().finish() } + builder.setPositiveButton(R.string.ssh_keygen_copy, null) } private fun readKeyFromFile() { diff --git a/app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenActivity.kt b/app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenActivity.kt index 2bb04e20..0f844b31 100644 --- a/app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenActivity.kt @@ -6,18 +6,48 @@ package com.zeapo.pwdstore.sshkeygen import android.os.Bundle import android.view.MenuItem +import android.view.View +import android.view.inputmethod.InputMethodManager import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import androidx.core.content.getSystemService +import androidx.lifecycle.lifecycleScope +import androidx.preference.PreferenceManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.jcraft.jsch.JSch +import com.jcraft.jsch.KeyPair +import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.databinding.ActivitySshKeygenBinding +import com.zeapo.pwdstore.utils.getEncryptedPrefs +import com.zeapo.pwdstore.utils.viewBinding +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream class SshKeyGenActivity : AppCompatActivity() { - public override fun onCreate(savedInstanceState: Bundle?) { + private var keyLength = 4096 + private val binding by viewBinding(ActivitySshKeygenBinding::inflate) + + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + setContentView(binding.root) supportActionBar?.setDisplayHomeAsUpEnabled(true) - if (savedInstanceState == null) { - supportFragmentManager - .beginTransaction() - .replace(android.R.id.content, SshKeyGenFragment()) - .commit() + with(binding) { + generate.setOnClickListener { + lifecycleScope.launch { generate(passphrase.text.toString(), comment.text.toString()) } + } + keyLengthGroup.check(R.id.key_length_4096) + keyLengthGroup.addOnButtonCheckedListener { _, checkedId, isChecked -> + if (isChecked) { + when (checkedId) { + R.id.key_length_2048 -> keyLength = 2048 + R.id.key_length_4096 -> keyLength = 4096 + } + } + } } } @@ -31,4 +61,56 @@ class SshKeyGenActivity : AppCompatActivity() { else -> super.onOptionsItemSelected(item) } } + + private suspend fun generate(passphrase: String, comment: String) { + binding.generate.text = getString(R.string.ssh_key_gen_generating_progress) + val e = try { + withContext(Dispatchers.IO) { + val kp = KeyPair.genKeyPair(JSch(), KeyPair.RSA, keyLength) + var file = File(filesDir, ".ssh_key") + var out = FileOutputStream(file, false) + if (passphrase.isNotEmpty()) { + kp?.writePrivateKey(out, passphrase.toByteArray()) + } else { + kp?.writePrivateKey(out) + } + file = File(filesDir, ".ssh_key.pub") + out = FileOutputStream(file, false) + kp?.writePublicKey(out, comment) + } + null + } catch (e: Exception) { + e.printStackTrace() + e + } finally { + getEncryptedPrefs("git_operation").edit { + remove("ssh_key_local_passphrase") + } + } + binding.generate.text = getString(R.string.ssh_keygen_generating_done) + if (e == null) { + val df = ShowSshKeyFragment() + df.show(supportFragmentManager, "public_key") + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + prefs.edit { putBoolean("use_generated_key", true) } + } else { + MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.error_generate_ssh_key)) + .setMessage(getString(R.string.ssh_key_error_dialog_text) + e.message) + .setPositiveButton(getString(R.string.dialog_ok)) { _, _ -> + finish() + } + .show() + } + hideKeyboard() + } + + private fun hideKeyboard() { + val imm = getSystemService<InputMethodManager>() ?: return + var view = currentFocus + if (view == null) { + view = View(this) + } + imm.hideSoftInputFromWindow(view.windowToken, 0) + } } diff --git a/app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenFragment.kt b/app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenFragment.kt deleted file mode 100644 index 9b9d58f5..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenFragment.kt +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.sshkeygen - -import android.os.Bundle -import android.view.View -import android.view.inputmethod.InputMethodManager -import androidx.core.content.edit -import androidx.core.content.getSystemService -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import androidx.preference.PreferenceManager -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.jcraft.jsch.JSch -import com.jcraft.jsch.KeyPair -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.databinding.FragmentSshKeygenBinding -import com.zeapo.pwdstore.utils.getEncryptedPrefs -import com.zeapo.pwdstore.utils.viewBinding -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.io.FileOutputStream - -class SshKeyGenFragment : Fragment(R.layout.fragment_ssh_keygen) { - - private var keyLength = 4096 - private val binding by viewBinding(FragmentSshKeygenBinding::bind) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - with(binding) { - generate.setOnClickListener { - lifecycleScope.launch { generate(passphrase.text.toString(), comment.text.toString()) } - } - keyLengthGroup.check(R.id.key_length_4096) - keyLengthGroup.addOnButtonCheckedListener { _, checkedId, isChecked -> - if (isChecked) { - when (checkedId) { - R.id.key_length_2048 -> keyLength = 2048 - R.id.key_length_4096 -> keyLength = 4096 - } - } - } - } - } - - override fun onDestroyView() { - super.onDestroyView() - } - - // Invoked when 'Generate' button of SshKeyGenFragment clicked. Generates a - // private and public key, then replaces the SshKeyGenFragment with a - // ShowSshKeyFragment which displays the public key. - private suspend fun generate(passphrase: String, comment: String) { - binding.generate.text = getString(R.string.ssh_key_gen_generating_progress) - val e = try { - withContext(Dispatchers.IO) { - val kp = KeyPair.genKeyPair(JSch(), KeyPair.RSA, keyLength) - var file = File(requireActivity().filesDir, ".ssh_key") - var out = FileOutputStream(file, false) - if (passphrase.isNotEmpty()) { - kp?.writePrivateKey(out, passphrase.toByteArray()) - } else { - kp?.writePrivateKey(out) - } - file = File(requireActivity().filesDir, ".ssh_key.pub") - out = FileOutputStream(file, false) - kp?.writePublicKey(out, comment) - } - null - } catch (e: Exception) { - e.printStackTrace() - e - } finally { - requireContext().getEncryptedPrefs("git_operation").edit { - remove("ssh_key_local_passphrase") - } - } - val activity = requireActivity() - binding.generate.text = getString(R.string.ssh_keygen_generating_done) - if (e == null) { - val df = ShowSshKeyFragment() - df.show(requireActivity().supportFragmentManager, "public_key") - val prefs = PreferenceManager.getDefaultSharedPreferences(activity) - prefs.edit { putBoolean("use_generated_key", true) } - } else { - MaterialAlertDialogBuilder(activity) - .setTitle(activity.getString(R.string.error_generate_ssh_key)) - .setMessage(activity.getString(R.string.ssh_key_error_dialog_text) + e.message) - .setPositiveButton(activity.getString(R.string.dialog_ok)) { _, _ -> - requireActivity().finish() - } - .show() - } - hideKeyboard() - } - - private fun hideKeyboard() { - val activity = activity ?: return - val imm = activity.getSystemService<InputMethodManager>() ?: return - var view = activity.currentFocus - if (view == null) { - view = View(activity) - } - imm.hideSoftInputFromWindow(view.windowToken, 0) - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/ui/adapters/PasswordItemRecyclerAdapter.kt b/app/src/main/java/com/zeapo/pwdstore/ui/adapters/PasswordItemRecyclerAdapter.kt index 91926640..fd96b7a8 100644 --- a/app/src/main/java/com/zeapo/pwdstore/ui/adapters/PasswordItemRecyclerAdapter.kt +++ b/app/src/main/java/com/zeapo/pwdstore/ui/adapters/PasswordItemRecyclerAdapter.kt @@ -18,6 +18,7 @@ import com.zeapo.pwdstore.R import com.zeapo.pwdstore.SearchableRepositoryAdapter import com.zeapo.pwdstore.stableId import com.zeapo.pwdstore.utils.PasswordItem +import com.zeapo.pwdstore.utils.PreferenceKeys import java.io.File open class PasswordItemRecyclerAdapter : @@ -50,7 +51,7 @@ open class PasswordItemRecyclerAdapter : fun bind(item: PasswordItem) { val settings = PreferenceManager.getDefaultSharedPreferences(itemView.context.applicationContext) - val showHidden = settings.getBoolean("show_hidden_folders", false) + val showHidden = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false) name.text = item.toString() if (item.type == PasswordItem.TYPE_CATEGORY) { typeImage.setImageResource(R.drawable.ic_multiple_files_24dp) diff --git a/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/PasswordGeneratorDialogFragment.kt b/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/PasswordGeneratorDialogFragment.kt index 160f388b..5528348f 100644 --- a/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/PasswordGeneratorDialogFragment.kt +++ b/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/PasswordGeneratorDialogFragment.kt @@ -24,6 +24,7 @@ import com.zeapo.pwdstore.pwgen.PasswordGenerator.PasswordGeneratorException import com.zeapo.pwdstore.pwgen.PasswordGenerator.generate import com.zeapo.pwdstore.pwgen.PasswordGenerator.setPrefs import com.zeapo.pwdstore.pwgen.PasswordOption +import com.zeapo.pwdstore.utils.PreferenceKeys class PasswordGeneratorDialogFragment : DialogFragment() { @@ -45,7 +46,7 @@ class PasswordGeneratorDialogFragment : DialogFragment() { view.findViewById<CheckBox>(R.id.pronounceable)?.isChecked = !prefs.getBoolean(PasswordOption.FullyRandom.key, true) val textView: AppCompatEditText = view.findViewById(R.id.lengthNumber) - textView.setText(prefs.getInt("length", 20).toString()) + textView.setText(prefs.getInt(PreferenceKeys.LENGTH, 20).toString()) val passwordText: AppCompatTextView = view.findViewById(R.id.passwordText) passwordText.typeface = monoTypeface return MaterialAlertDialogBuilder(requireContext()).run { diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt b/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt index c84465e9..dcede7da 100644 --- a/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt +++ b/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt @@ -23,7 +23,6 @@ import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import com.github.ajalt.timberkt.d import com.google.android.material.snackbar.Snackbar -import com.zeapo.pwdstore.PasswordStore import com.zeapo.pwdstore.git.GitAsyncTask import com.zeapo.pwdstore.git.GitOperation import com.zeapo.pwdstore.utils.PasswordRepository.Companion.getRepositoryDirectory @@ -92,7 +91,7 @@ fun Activity.commitChange(message: String, finishWithResultOnEnd: Intent? = null override fun execute() { d { "Comitting with message: '$message'" } val git = Git(repository) - val task = GitAsyncTask(this@commitChange, true, this, finishWithResultOnEnd, silentlyExecute = true) + val task = GitAsyncTask(this@commitChange, this, finishWithResultOnEnd, silentlyExecute = true) task.execute( git.add().addFilepattern("."), git.commit().setAll(true).setMessage(message) diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/Otp.kt b/app/src/main/java/com/zeapo/pwdstore/utils/Otp.kt new file mode 100644 index 00000000..b95b9902 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/utils/Otp.kt @@ -0,0 +1,88 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.zeapo.pwdstore.utils + +import com.github.ajalt.timberkt.e +import org.apache.commons.codec.binary.Base32 +import java.nio.ByteBuffer +import java.security.InvalidKeyException +import java.security.NoSuchAlgorithmException +import java.util.Locale +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import kotlin.experimental.and + +object Otp { + private val BASE_32 = Base32() + private val STEAM_ALPHABET = "23456789BCDFGHJKMNPQRTVWXY".toCharArray() + init { + check(STEAM_ALPHABET.size == 26) + } + + fun calculateCode(secret: String, counter: Long, algorithm: String, digits: String): String? { + val algo = "Hmac${algorithm.toUpperCase(Locale.ROOT)}" + val decodedSecret = try { + BASE_32.decode(secret) + } catch (e: Exception) { + e(e) { "Failed to decode secret" } + return null + } + val secretKey = SecretKeySpec(decodedSecret, algo) + val digest = try { + Mac.getInstance(algo).run { + init(secretKey) + doFinal(ByteBuffer.allocate(8).putLong(counter).array()) + } + } catch (e: NoSuchAlgorithmException) { + e(e) + return null + } catch (e: InvalidKeyException) { + e(e) { "Key is malformed" } + return null + } + // Least significant 4 bits are used as an offset into the digest. + val offset = (digest.last() and 0xf).toInt() + // Extract 32 bits at the offset and clear the most significant bit. + val code = digest.copyOfRange(offset, offset + 4) + code[0] = (0x7f and code[0].toInt()).toByte() + val codeInt = ByteBuffer.wrap(code).int + check(codeInt > 0) + return if (digits == "s") { + // Steam + var remainingCodeInt = codeInt + buildString { + repeat(5) { + append(STEAM_ALPHABET[remainingCodeInt % 26]) + remainingCodeInt /= 26 + } + } + } else { + // Base 10, 6 to 10 digits + val numDigits = digits.toIntOrNull() + when { + numDigits == null -> { + e { "Digits specifier has to be either 's' or numeric" } + return null + } + numDigits < 6 -> { + e { "TOTP codes have to be at least 6 digits long" } + return null + } + numDigits > 10 -> { + e { "TOTP codes can be at most 10 digits long" } + return null + } + else -> { + // 2^31 = 2_147_483_648, so we can extract at most 10 digits with the first one + // always being 0, 1, or 2. Pad with leading zeroes. + val codeStringBase10 = codeInt.toString(10).padStart(10, '0') + check(codeStringBase10.length == 10) + codeStringBase10.takeLast(numDigits) + } + } + } + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/PasswordRepository.kt b/app/src/main/java/com/zeapo/pwdstore/utils/PasswordRepository.kt index 794630be..65109d99 100644 --- a/app/src/main/java/com/zeapo/pwdstore/utils/PasswordRepository.kt +++ b/app/src/main/java/com/zeapo/pwdstore/utils/PasswordRepository.kt @@ -39,7 +39,8 @@ open class PasswordRepository protected constructor() { companion object { @JvmStatic fun getSortOrder(settings: SharedPreferences): PasswordSortOrder { - return valueOf(settings.getString("sort_order", null) ?: FOLDER_FIRST.name) + return valueOf(settings.getString(PreferenceKeys.SORT_ORDER, null) + ?: FOLDER_FIRST.name) } } } @@ -154,8 +155,8 @@ open class PasswordRepository protected constructor() { if (!::settings.isInitialized) { settings = PreferenceManager.getDefaultSharedPreferences(context.applicationContext) } - return if (settings.getBoolean("git_external", false)) { - val externalRepo = settings.getString("git_external_repo", null) + return if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) { + val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO, null) if (externalRepo != null) File(externalRepo) else @@ -174,9 +175,9 @@ open class PasswordRepository protected constructor() { // uninitialize the repo if the dir does not exist or is absolutely empty settings.edit { if (!dir.exists() || !dir.isDirectory || dir.listFiles()!!.isEmpty()) { - putBoolean("repository_initialized", false) + putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) } else { - putBoolean("repository_initialized", true) + putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) } } @@ -217,7 +218,7 @@ open class PasswordRepository protected constructor() { // We need to recover the passwords then parse the files val passList = getFilesList(path).also { it.sortBy { f -> f.name } } val passwordList = ArrayList<PasswordItem>() - val showHiddenDirs = settings.getBoolean("show_hidden_folders", false) + val showHiddenDirs = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false) if (passList.size == 0) return passwordList if (showHiddenDirs) { diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/PreferenceKeys.kt b/app/src/main/java/com/zeapo/pwdstore/utils/PreferenceKeys.kt new file mode 100644 index 00000000..05f9c741 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/utils/PreferenceKeys.kt @@ -0,0 +1,60 @@ +package com.zeapo.pwdstore.utils + +object PreferenceKeys { + + const val APP_THEME = "app_theme" + const val APP_VERSION = "app_version" + const val AUTOFILL_APPS = "autofill_apps" + const val AUTOFILL_ALWAYS = "autofill_always" + const val AUTOFILL_DEFAULT = "autofill_default" + const val AUTOFILL_ENABLE = "autofill_enable" + const val AUTOFILL_FULL_PATH = "autofill_full_path" + const val BIOMETRIC_AUTH = "biometric_auth" + const val CLEAR_CLIPBOARD_20X = "clear_clipboard_20x" + const val CLEAR_SAVED_PASS = "clear_saved_pass" + const val COPY_ON_DECRYPT = "copy_on_decrypt" + const val ENABLE_DEBUG_LOGGING = "enable_debug_logging" + const val EXPORT_PASSWORDS = "export_passwords" + const val FILTER_RECURSIVELY = "filter_recursively" + const val GENERAL_SHOW_TIME = "general_show_time" + const val GIT_CONFIG = "git_config" + const val GIT_CONFIG_USER_EMAIL = "git_config_user_email" + const val GIT_CONFIG_USER_NAME = "git_config_user_name" + const val GIT_EXTERNAL = "git_external" + const val GIT_EXTERNAL_REPO = "git_external_repo" + const val GIT_REMOTE_AUTH = "git_remote_auth" + const val GIT_REMOTE_LOCATION = "git_remote_location" + const val GIT_REMOTE_PORT = "git_remote_port" + const val GIT_REMOTE_PROTOCOL = "git_remote_protocol" + const val GIT_DELETE_REPO = "git_delete_repo" + const val GIT_REMOTE_SERVER = "git_remote_server" + const val GIT_REMOTE_USERNAME = "git_remote_username" + const val GIT_SERVER_INFO = "git_server_info" + const val HTTPS_PASSWORD = "https_password" + const val LENGTH = "length" + const val OPENPGP_KEY_IDS_SET = "openpgp_key_ids_set" + const val OPENPGP_KEY_ID_PREF = "openpgp_key_id_pref" + const val OPENPGP_PROVIDER_LIST = "openpgp_provider_list" + const val OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES = "oreo_autofill_custom_public_suffixes" + const val OREO_AUTOFILL_DEFAULT_USERNAME = "oreo_autofill_default_username" + const val OREO_AUTOFILL_DIRECTORY_STRUCTURE = "oreo_autofill_directory_structure" + const val PREF_KEY_CUSTOM_DICT = "pref_key_custom_dict" + const val PREF_KEY_IS_CUSTOM_DICT = "pref_key_is_custom_dict" + const val PREF_KEY_PWGEN_TYPE = "pref_key_pwgen_type" + const val PREF_SELECT_EXTERNAL = "pref_select_external" + const val REPOSITORY_INITIALIZED = "repository_initialized" + const val REPO_CHANGED = "repo_changed" + const val SEARCH_ON_START = "search_on_start" + const val SHOW_EXTRA_CONTENT = "show_extra_content" + const val SHOW_HIDDEN_FOLDERS = "show_hidden_folders" + const val SORT_ORDER = "sort_order" + const val SHOW_PASSWORD = "show_password" + const val SSH_KEY = "ssh_key" + const val SSH_KEYGEN = "ssh_keygen" + const val SSH_KEY_LOCAL_PASSPHRASE = "ssh_key_local_passphrase" + const val SSH_OPENKEYSTORE_CLEAR_KEY_ID = "ssh_openkeystore_clear_keyid" + const val SSH_OPENKEYSTORE_KEYID = "ssh_openkeystore_keyid" + const val SSH_SEE_KEY = "ssh_see_key" + const val USE_GENERATED_KEY = "use_generated_key" + +} diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/TotpFinder.kt b/app/src/main/java/com/zeapo/pwdstore/utils/TotpFinder.kt new file mode 100644 index 00000000..13a47543 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/utils/TotpFinder.kt @@ -0,0 +1,32 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.zeapo.pwdstore.utils + +/** + * Defines a class that can extract relevant parts of a TOTP URL for use by the app. + */ +interface TotpFinder { + + /** + * Get the TOTP secret from the given extra content. + */ + fun findSecret(content: String): String? + + /** + * Get the number of digits required in the final OTP. + */ + fun findDigits(content: String): String + + /** + * Get the TOTP timeout period. + */ + fun findPeriod(content: String): Long + + /** + * Get the algorithm for the TOTP secret. + */ + fun findAlgorithm(content: String): String +} diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/UriTotpFinder.kt b/app/src/main/java/com/zeapo/pwdstore/utils/UriTotpFinder.kt new file mode 100644 index 00000000..faa349d1 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/utils/UriTotpFinder.kt @@ -0,0 +1,57 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.zeapo.pwdstore.utils + +import android.net.Uri + +/** + * [Uri] backed TOTP URL parser. + */ +class UriTotpFinder : TotpFinder { + override fun findSecret(content: String): String? { + content.split("\n".toRegex()).forEach { line -> + if (line.startsWith("otpauth://totp/")) { + return Uri.parse(line).getQueryParameter("secret") + } + if (line.startsWith("totp:", ignoreCase = true)) { + return line.split(": *".toRegex(), 2).toTypedArray()[1] + } + } + return null + } + + override fun findDigits(content: String): String { + content.split("\n".toRegex()).forEach { line -> + if (line.startsWith("otpauth://totp/") && + Uri.parse(line).getQueryParameter("digits") != null) { + return Uri.parse(line).getQueryParameter("digits")!! + } + } + return "6" + } + + override fun findPeriod(content: String): Long { + content.split("\n".toRegex()).forEach { line -> + if (line.startsWith("otpauth://totp/") && + Uri.parse(line).getQueryParameter("period") != null) { + val period = Uri.parse(line).getQueryParameter("period")!!.toLongOrNull() + if (period != null && period > 0) + return period + } + } + return 30 + } + + override fun findAlgorithm(content: String): String { + content.split("\n".toRegex()).forEach { line -> + if (line.startsWith("otpauth://totp/") && + Uri.parse(line).getQueryParameter("algorithm") != null) { + return Uri.parse(line).getQueryParameter("algorithm")!! + } + } + return "sha1" + } +} diff --git a/app/src/main/res/drawable/ic_qr_code_scanner.xml b/app/src/main/res/drawable/ic_qr_code_scanner.xml new file mode 100644 index 00000000..45a618ac --- /dev/null +++ b/app/src/main/res/drawable/ic_qr_code_scanner.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M9.5,6.5v3h-3v-3H9.5M11,5H5v6h6V5L11,5zM9.5,14.5v3h-3v-3H9.5M11,13H5v6h6V13L11,13zM17.5,6.5v3h-3v-3H17.5M19,5h-6v6h6V5L19,5zM13,13h1.5v1.5H13V13zM14.5,14.5H16V16h-1.5V14.5zM16,13h1.5v1.5H16V13zM13,16h1.5v1.5H13V16zM14.5,17.5H16V19h-1.5V17.5zM16,16h1.5v1.5H16V16zM17.5,14.5H19V16h-1.5V14.5zM17.5,17.5H19V19h-1.5V17.5zM22,7h-2V4h-3V2h5V7zM22,22v-5h-2v3h-3v2H22zM2,22h5v-2H4v-3H2V22zM2,2v5h2V4h3V2H2z" + android:fillColor="#000000"/> +</vector> diff --git a/app/src/main/res/layout/fragment_ssh_keygen.xml b/app/src/main/res/layout/activity_ssh_keygen.xml index 6ec3f2fb..6ec3f2fb 100644 --- a/app/src/main/res/layout/fragment_ssh_keygen.xml +++ b/app/src/main/res/layout/activity_ssh_keygen.xml diff --git a/app/src/main/res/layout/decrypt_layout.xml b/app/src/main/res/layout/decrypt_layout.xml index 664cb482..6e2bf14c 100644 --- a/app/src/main/res/layout/decrypt_layout.xml +++ b/app/src/main/res/layout/decrypt_layout.xml @@ -10,7 +10,7 @@ android:layout_height="match_parent" android:background="?android:attr/windowBackground" android:orientation="vertical" - tools:context="com.zeapo.pwdstore.crypto.PgpActivity"> + tools:context="com.zeapo.pwdstore.crypto.DecryptActivity"> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="fill_parent" @@ -91,6 +91,29 @@ </com.google.android.material.textfield.TextInputLayout> <com.google.android.material.textfield.TextInputLayout + android:id="@+id/otp_text_container" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="16dp" + android:hint="@string/otp" + android:visibility="gone" + app:endIconDrawable="@drawable/ic_content_copy" + app:endIconMode="custom" + app:layout_constraintTop_toBottomOf="@id/password_text_container" + tools:visibility="visible"> + + <com.google.android.material.textfield.TextInputEditText + android:id="@+id/otp_text" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:editable="false" + android:fontFamily="@font/sourcecodepro" + android:textIsSelectable="true" + tools:text="123456" /> + + </com.google.android.material.textfield.TextInputLayout> + + <com.google.android.material.textfield.TextInputLayout android:id="@+id/username_text_container" android:layout_width="match_parent" android:layout_height="wrap_content" @@ -99,7 +122,7 @@ android:visibility="gone" app:endIconDrawable="@drawable/ic_content_copy" app:endIconMode="custom" - app:layout_constraintTop_toBottomOf="@id/password_text_container" + app:layout_constraintTop_toBottomOf="@id/otp_text_container" tools:visibility="visible"> <com.google.android.material.textfield.TextInputEditText diff --git a/app/src/main/res/layout/password_creation_activity.xml b/app/src/main/res/layout/password_creation_activity.xml index 13af597c..e0b25786 100644 --- a/app/src/main/res/layout/password_creation_activity.xml +++ b/app/src/main/res/layout/password_creation_activity.xml @@ -84,6 +84,17 @@ </com.google.android.material.textfield.TextInputLayout> + <com.google.android.material.button.MaterialButton + android:id="@+id/otp_import_button" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_margin="8dp" + android:text="@string/add_otp" + app:icon="@drawable/ic_qr_code_scanner" + app:iconTint="?attr/colorOnSecondary" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toBottomOf="@id/extra_input_layout" /> + <com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/encrypt_username" android:layout_width="match_parent" @@ -92,6 +103,6 @@ android:text="@string/crypto_encrypt_username_label" android:visibility="gone" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/extra_input_layout" + app:layout_constraintTop_toBottomOf="@id/otp_import_button" tools:visibility="visible" /> </androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/password_row_layout.xml b/app/src/main/res/layout/password_row_layout.xml index 30d9c40f..93da710e 100644 --- a/app/src/main/res/layout/password_row_layout.xml +++ b/app/src/main/res/layout/password_row_layout.xml @@ -26,12 +26,13 @@ <TextView android:id="@+id/label" - android:layout_width="wrap_content" + android:layout_width="0dp" android:layout_height="wrap_content" android:layout_gravity="bottom" android:textSize="18sp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toEndOf="@id/type_image" + app:layout_constraintEnd_toStartOf="@id/child_count" app:layout_constraintTop_toTopOf="parent" tools:text="FILE_NAME" /> diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 558b7a26..4eecf660 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -194,7 +194,6 @@ <string name="git_push_nff_error">La subida fue rechazada por el servidor, Ejecuta \'Descargar desde servidor\' antes de subir o pulsa \'Sincronizar con servidor\' para realizar ambas acciones.</string> <string name="git_push_generic_error">El envío fue rechazado por el servidor, la razón:</string> <string name="jgit_error_push_dialog_text">Ocurrió un error durante el envío:</string> - <string name="hotp_remember_clear_choice">Limpiar preferencia para incremento HOTP</string> <string name="git_operation_remember_passphrase">Recordar contraseñagit (inseguro)</string> <string name="hackish_tools">Hackish tools</string> <string name="abort_rebase">Abortar rebase</string> diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 76edb963..c4c75b55 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -194,7 +194,6 @@ <string name="git_push_generic_error">Poussée rejetée par le dépôt distant, raison:</string> <string name="git_push_other_error">Pousser au dépôt distant sans avance rapide rejetée. Vérifiez la variable receive.denyNonFastForwards dans le fichier de configuration du répertoire de destination.</string> <string name="jgit_error_push_dialog_text">Une erreur s\'est produite lors de l\'opération de poussée:</string> - <string name="hotp_remember_clear_choice">Effacer les préférences enregistrées pour l’incrémentation HOTP</string> <string name="git_operation_remember_passphrase">Se rappeler de la phrase secrète dans la configuration de l\'application (peu sûr)</string> <string name="hackish_tools">Outils de hack</string> <string name="commit_hash">Commettre la clé</string> diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 99217355..ff36585b 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -253,7 +253,6 @@ <string name="git_push_generic_error">Запись изменений была отклонена удаленным репозиторием, причина:</string> <string name="git_push_other_error">Удаленный репозиторий отклонил запись изменений без быстрой перемотки вперед. Проверьте переменную receive.denyNonFastForwards в файле конфигурации репозитория назначения.</string> <string name="jgit_error_push_dialog_text">В хоте операции записи изменений возникла ошибка:</string> - <string name="hotp_remember_clear_choice">Очистить сохраненные настройки для увеличения HOTP</string> <string name="git_operation_remember_passphrase">Заполнить парольную фразу в конфигурации приложнеия (небезопасно)</string> <string name="hackish_tools">Костыльные инструменты</string> <string name="abort_rebase">Прервать перебазирование и записать изменения в новую ветку</string> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c4f38a9f..3023d995 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -54,6 +54,7 @@ <string name="clipboard_password_toast_text">Password copied to clipboard, you have %d seconds to paste it somewhere.</string> <string name="clipboard_password_no_clear_toast_text">Password copied to clipboard</string> <string name="clipboard_copied_text">Copied to clipboard</string> + <string name="clipboard_otp_copied_text">OTP code copied to clipboard</string> <string name="file_toast_text">Please provide a file name</string> <string name="path_toast_text">Please provide a file path</string> <string name="empty_toast_text">You cannot use an empty password or empty extra content</string> @@ -111,6 +112,7 @@ <!-- DECRYPT Layout --> <string name="action_search">Search</string> <string name="password">Password:</string> + <string name="otp">OTP:</string> <string name="extra_content">Extra content:</string> <string name="username">Username:</string> <string name="edit_password">Edit password</string> @@ -118,6 +120,7 @@ <string name="copy_username">Copy username</string> <string name="share_as_plaintext">Share as plaintext</string> <string name="last_changed">Last changed %s</string> + <string name="view_otp">View OTP</string> <!-- Preferences --> <string name="pref_repository_title">Repository</string> @@ -297,7 +300,6 @@ <string name="jgit_error_push_dialog_text">Error occurred during the push operation:</string> <string name="clear_saved_passphrase_ssh">Clear saved passphrase for local SSH key</string> <string name="clear_saved_passphrase_https">Clear saved HTTPS password</string> - <string name="hotp_remember_clear_choice">Clear saved preference for HOTP incrementing</string> <string name="git_operation_remember_passphrase">Remember key passphrase</string> <string name="hackish_tools">Hackish tools</string> <string name="abort_rebase">Abort rebase and push new branch</string> @@ -381,4 +383,7 @@ <string name="password_creation_file_write_fail_message">Failed to write password file to the store, please try again.</string> <string name="password_creation_file_delete_fail_message">Failed to delete password file %1$s from the store, please delete it manually.</string> <string name="password_creation_duplicate_error">File already exists, please use a different name</string> + <string name="add_otp">Add OTP</string> + <string name="otp_import_success">Successfully imported TOTP configuration</string> + <string name="otp_import_failure">Failed to import TOTP configuration</string> </resources> diff --git a/app/src/test/java/com/zeapo/pwdstore/PasswordEntryTest.kt b/app/src/test/java/com/zeapo/pwdstore/PasswordEntryTest.kt deleted file mode 100644 index 2074f40b..00000000 --- a/app/src/test/java/com/zeapo/pwdstore/PasswordEntryTest.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore - -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class PasswordEntryTest { - @Test fun testGetPassword() { - assertEquals("fooooo", PasswordEntry("fooooo\nbla\n").password) - assertEquals("fooooo", PasswordEntry("fooooo\nbla").password) - assertEquals("fooooo", PasswordEntry("fooooo\n").password) - assertEquals("fooooo", PasswordEntry("fooooo").password) - assertEquals("", PasswordEntry("\nblubb\n").password) - assertEquals("", PasswordEntry("\nblubb").password) - assertEquals("", PasswordEntry("\n").password) - assertEquals("", PasswordEntry("").password) - } - - @Test fun testGetExtraContent() { - assertEquals("bla\n", PasswordEntry("fooooo\nbla\n").extraContent) - assertEquals("bla", PasswordEntry("fooooo\nbla").extraContent) - assertEquals("", PasswordEntry("fooooo\n").extraContent) - assertEquals("", PasswordEntry("fooooo").extraContent) - assertEquals("blubb\n", PasswordEntry("\nblubb\n").extraContent) - assertEquals("blubb", PasswordEntry("\nblubb").extraContent) - assertEquals("", PasswordEntry("\n").extraContent) - assertEquals("", PasswordEntry("").extraContent) - } - - @Test fun testGetUsername() { - for (field in PasswordEntry.USERNAME_FIELDS) { - assertEquals("username", PasswordEntry("\n$field username").username) - assertEquals("username", PasswordEntry("\n${field.toUpperCase()} username").username) - } - assertEquals( - "username", - PasswordEntry("secret\nextra\nlogin: username\ncontent\n").username) - assertEquals( - "username", - PasswordEntry("\nextra\nusername: username\ncontent\n").username) - assertEquals( - "username", PasswordEntry("\nUSERNaMe: username\ncontent\n").username) - assertEquals("username", PasswordEntry("\nlogin: username").username) - assertEquals("foo@example.com", PasswordEntry("\nemail: foo@example.com").username) - assertEquals("username", PasswordEntry("\nidentity: username\nlogin: another_username").username) - assertEquals("username", PasswordEntry("\nLOGiN:username").username) - assertNull(PasswordEntry("secret\nextra\ncontent\n").username) - } - - @Test fun testHasUsername() { - assertTrue(PasswordEntry("secret\nextra\nlogin: username\ncontent\n").hasUsername()) - assertFalse(PasswordEntry("secret\nextra\ncontent\n").hasUsername()) - assertFalse(PasswordEntry("secret\nlogin failed\n").hasUsername()) - assertFalse(PasswordEntry("\n").hasUsername()) - assertFalse(PasswordEntry("").hasUsername()) - } -} diff --git a/app/src/test/java/com/zeapo/pwdstore/model/PasswordEntryTest.kt b/app/src/test/java/com/zeapo/pwdstore/model/PasswordEntryTest.kt new file mode 100644 index 00000000..f31709df --- /dev/null +++ b/app/src/test/java/com/zeapo/pwdstore/model/PasswordEntryTest.kt @@ -0,0 +1,107 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.model + +import com.zeapo.pwdstore.utils.Otp +import com.zeapo.pwdstore.utils.TotpFinder +import org.junit.Test +import java.util.Date +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PasswordEntryTest { + private fun makeEntry(content: String) = PasswordEntry(content, testFinder) + + @Test fun testGetPassword() { + assertEquals("fooooo", makeEntry("fooooo\nbla\n").password) + assertEquals("fooooo", makeEntry("fooooo\nbla").password) + assertEquals("fooooo", makeEntry("fooooo\n").password) + assertEquals("fooooo", makeEntry("fooooo").password) + assertEquals("", makeEntry("\nblubb\n").password) + assertEquals("", makeEntry("\nblubb").password) + assertEquals("", makeEntry("\n").password) + assertEquals("", makeEntry("").password) + } + + @Test fun testGetExtraContent() { + assertEquals("bla\n", makeEntry("fooooo\nbla\n").extraContent) + assertEquals("bla", makeEntry("fooooo\nbla").extraContent) + assertEquals("", makeEntry("fooooo\n").extraContent) + assertEquals("", makeEntry("fooooo").extraContent) + assertEquals("blubb\n", makeEntry("\nblubb\n").extraContent) + assertEquals("blubb", makeEntry("\nblubb").extraContent) + assertEquals("", makeEntry("\n").extraContent) + assertEquals("", makeEntry("").extraContent) + } + + @Test fun testGetUsername() { + for (field in PasswordEntry.USERNAME_FIELDS) { + assertEquals("username", makeEntry("\n$field username").username) + assertEquals("username", makeEntry("\n${field.toUpperCase()} username").username) + } + assertEquals( + "username", + makeEntry("secret\nextra\nlogin: username\ncontent\n").username) + assertEquals( + "username", + makeEntry("\nextra\nusername: username\ncontent\n").username) + assertEquals( + "username", makeEntry("\nUSERNaMe: username\ncontent\n").username) + assertEquals("username", makeEntry("\nlogin: username").username) + assertEquals("foo@example.com", makeEntry("\nemail: foo@example.com").username) + assertEquals("username", makeEntry("\nidentity: username\nlogin: another_username").username) + assertEquals("username", makeEntry("\nLOGiN:username").username) + assertNull(makeEntry("secret\nextra\ncontent\n").username) + } + + @Test fun testHasUsername() { + assertTrue(makeEntry("secret\nextra\nlogin: username\ncontent\n").hasUsername()) + assertFalse(makeEntry("secret\nextra\ncontent\n").hasUsername()) + assertFalse(makeEntry("secret\nlogin failed\n").hasUsername()) + assertFalse(makeEntry("\n").hasUsername()) + assertFalse(makeEntry("").hasUsername()) + } + + @Test fun testGeneratesOtpFromTotpUri() { + val entry = makeEntry("secret\nextra\n$TOTP_URI") + assertTrue(entry.hasTotp()) + val code = Otp.calculateCode( + entry.totpSecret!!, + // The hardcoded date value allows this test to stay reproducible. + Date(8640000).time / (1000 * entry.totpPeriod), + entry.totpAlgorithm, + entry.digits + ) + assertNotNull(code) { "Generated OTP cannot be null" } + assertEquals(entry.digits.toInt(), code.length) + assertEquals("545293", code) + } + + companion object { + const val TOTP_URI = "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30" + + // This implementation is hardcoded for the URI above. + val testFinder = object : TotpFinder { + override fun findSecret(content: String): String? { + return "HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ" + } + + override fun findDigits(content: String): String { + return "6" + } + + override fun findPeriod(content: String): Long { + return 30 + } + + override fun findAlgorithm(content: String): String { + return "SHA1" + } + } + } +} diff --git a/app/src/test/java/com/zeapo/pwdstore/utils/OtpTest.kt b/app/src/test/java/com/zeapo/pwdstore/utils/OtpTest.kt new file mode 100644 index 00000000..710b0845 --- /dev/null +++ b/app/src/test/java/com/zeapo/pwdstore/utils/OtpTest.kt @@ -0,0 +1,50 @@ +package com.zeapo.pwdstore.utils + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class OtpTest { + + @Test + fun testOtpGeneration6Digits() { + assertEquals("953550", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333298159 / (1000 * 30), "SHA1", "6")) + assertEquals("275379", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333571918 / (1000 * 30), "SHA1", "6")) + assertEquals("867507", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333600517 / (1000 * 57), "SHA1", "6")) + } + + @Test + fun testOtpGeneration10Digits() { + assertEquals("0740900914", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333655044 / (1000 * 30), "SHA1", "10")) + assertEquals("0070632029", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333691405 / (1000 * 30), "SHA1", "10")) + assertEquals("1017265882", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333728893 / (1000 * 83), "SHA1", "10")) + } + + @Test + fun testOtpGenerationIllegalInput() { + assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA0", "10")) + assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA1", "a")) + assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA1", "5")) + assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA1", "11")) + assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAB", 10000, "SHA1", "6")) + } + + @Test + fun testOtpGenerationUnusualSecrets() { + assertEquals("127764", Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAAAAA", 1593367111963 / (1000 * 30), "SHA1", "6")) + assertEquals("047515", Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAA", 1593367171420 / (1000 * 30), "SHA1", "6")) + } + + @Test + fun testOtpGenerationUnpaddedSecrets() { + // Secret was generated with `echo 'string with some padding needed' | base32` + // We don't care for the resultant OTP's actual value, we just want both the padded and + // unpadded variant to generate the same one. + val unpaddedOtp = Otp.calculateCode("ON2HE2LOM4QHO2LUNAQHG33NMUQHAYLEMRUW4ZZANZSWKZDFMQFA", 1593367171420 / (1000 * 30), "SHA1", "6") + val paddedOtp = Otp.calculateCode("ON2HE2LOM4QHO2LUNAQHG33NMUQHAYLEMRUW4ZZANZSWKZDFMQFA====", 1593367171420 / (1000 * 30), "SHA1", "6") + assertNotNull(unpaddedOtp) + assertNotNull(paddedOtp) + assertEquals(unpaddedOtp, paddedOtp) + } +} diff --git a/dependencies.gradle b/dependencies.gradle index fe0d0cbd..f5c7c965 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -24,38 +24,44 @@ ext.deps = [ ], androidx: [ - annotation: 'androidx.annotation:annotation:1.2.0-alpha01', activity_ktx: 'androidx.activity:activity-ktx:1.2.0-alpha06', + annotation: 'androidx.annotation:annotation:1.2.0-alpha01', + autofill: 'androidx.autofill:autofill:1.0.0', appcompat: 'androidx.appcompat:appcompat:1.3.0-alpha01', - biometric: 'androidx.biometric:biometric:1.0.1', - constraint_layout: 'androidx.constraintlayout:constraintlayout:2.0.0-beta6', + biometric: 'androidx.biometric:biometric:1.1.0-alpha01', + constraint_layout: 'androidx.constraintlayout:constraintlayout:2.0.0-beta7', core_ktx: 'androidx.core:core-ktx:1.5.0-alpha01', documentfile: 'androidx.documentfile:documentfile:1.0.1', fragment_ktx: 'androidx.fragment:fragment-ktx:1.3.0-alpha06', - lifecycle_common: 'androidx.lifecycle:lifecycle-common-java8:2.3.0-alpha04', - lifecycle_livedata_ktx: 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.0-alpha04', - lifecycle_viewmodel_ktx: 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.0-alpha04', + lifecycle_common: 'androidx.lifecycle:lifecycle-common-java8:2.3.0-alpha05', + lifecycle_livedata_ktx: 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.0-alpha05', + lifecycle_viewmodel_ktx: 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.0-alpha05', material: 'com.google.android.material:material:1.3.0-alpha01', preference: 'androidx.preference:preference:1.1.1', - recycler_view: 'androidx.recyclerview:recyclerview:1.2.0-alpha03', + recycler_view: 'androidx.recyclerview:recyclerview:1.2.0-alpha04', recycler_view_selection: 'androidx.recyclerview:recyclerview-selection:1.1.0-rc01', security: 'androidx.security:security-crypto:1.1.0-alpha01', - swiperefreshlayout: 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-rc01' + swiperefreshlayout: 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' + ], + + first_party: [ + openpgp_ktx: 'com.github.android-password-store:openpgp-ktx:2.0.0', + zxing_android_embedded: 'com.github.android-password-store:zxing-android-embedded:v4.1.0-aps' ], third_party: [ - bouncycastle: 'org.bouncycastle:bcprov-jdk15on:1.65', + bouncycastle: 'org.bouncycastle:bcprov-jdk15on:1.65.01', + commons_codec: 'commons-codec:commons-codec:1.13', fastscroll: 'me.zhanghai.android.fastscroll:library:1.1.4', jsch: 'com.jcraft:jsch:0.1.55', jgit: 'org.eclipse.jgit:org.eclipse.jgit:3.7.1.201504261725-r', leakcanary: 'com.squareup.leakcanary:leakcanary-android:2.4', plumber: 'com.squareup.leakcanary:plumber-android:2.4', - openpgp_ktx: 'com.github.android-password-store:openpgp-ktx:2.0.0', sshj: 'com.hierynomus:sshj:0.29.0', ssh_auth: 'org.sufficientlysecure:sshauthentication-api:1.0', timber: 'com.jakewharton.timber:timber:4.7.1', timberkt: 'com.github.ajalt:timberkt:1.5.1', - whatthestack: 'com.github.haroldadmin:WhatTheStack:0.0.2', + whatthestack: 'com.github.haroldadmin:WhatTheStack:0.0.3', ], testing: [ diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 9a274bca..84bf11de 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -1,13 +1,14 @@ -Simple password manager that is compatible with [https://www.passwordstore.org/ -pass]: Passwords are stored in simple text files which are encrypted with -OpenPGP. +<p>Simple password manager that is compatible with <a href="https://www.passwordstore.org/">pass</a>: Passwords are stored in simple text files which are encrypted with OpenPGP.</p> -Requires [[org.sufficientlysecure.keychain]] to encrypt and decrypt passwords. +<p>Requires <a href="https://f-droid.org/en/packages/org.sufficientlysecure.keychain/">OpenKeychain</a> to encrypt and decrypt passwords.</p> -'''Features:''' - -* Clone an existing pass repository or start a new one -* Create and organize password files -* Sync with a remote Git repository -* Decrypt and copy passwords -* Automatically fill and save credentials in apps and supported browsers +<p> +<strong>Features:</strong> +<ul> +<li>Clone an existing pass repository or start a new one</li> +<li>Create and organize password files</li> +<li>Sync with a remote Git repository</li> +<li>Decrypt and copy passwords</li> +<li>Automatically fill and save credentials in apps and supported browsers</li> +</ul> +</p> |