diff options
Diffstat (limited to 'app/src')
24 files changed, 515 insertions, 625 deletions
diff --git a/app/src/main/java/com/zeapo/pwdstore/Application.kt b/app/src/main/java/com/zeapo/pwdstore/Application.kt index 544eb047..3108dee3 100644 --- a/app/src/main/java/com/zeapo/pwdstore/Application.kt +++ b/app/src/main/java/com/zeapo/pwdstore/Application.kt @@ -12,7 +12,7 @@ import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_NO import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES import com.github.ajalt.timberkt.Timber.DebugTree import com.github.ajalt.timberkt.Timber.plant -import com.zeapo.pwdstore.git.config.setUpBouncyCastleForSshj +import com.zeapo.pwdstore.git.sshj.setUpBouncyCastleForSshj import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.sharedPrefs import com.zeapo.pwdstore.utils.getString diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt index 6657afaf..9e50ad2b 100644 --- a/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt +++ b/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt @@ -16,6 +16,7 @@ import android.view.animation.Animation import android.view.animation.AnimationUtils import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.appcompat.view.ActionMode +import androidx.core.content.edit import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.DividerItemDecoration @@ -32,6 +33,9 @@ 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.base64 +import com.zeapo.pwdstore.utils.getString import com.zeapo.pwdstore.utils.sharedPrefs import com.zeapo.pwdstore.utils.viewBinding import java.io.File @@ -243,6 +247,14 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) { try { listener = object : OnFragmentInteractionListener { override fun onFragmentInteraction(item: PasswordItem) { + if (settings.getString(PreferenceKeys.SORT_ORDER) == PasswordRepository.PasswordSortOrder.RECENTLY_USED.name) { + //save the time when password was used + val preferences = context.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) + preferences.edit { + putString(item.file.absolutePath.base64(), System.currentTimeMillis().toString()) + } + } + if (item.type == PasswordItem.TYPE_CATEGORY) { navigateTo(item.file) } else { diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt index 3ebd35b8..d70417bc 100644 --- a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt +++ b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt @@ -6,6 +6,7 @@ package com.zeapo.pwdstore import android.Manifest import android.annotation.SuppressLint +import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ShortcutInfo.Builder @@ -63,6 +64,7 @@ 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.base64 import com.zeapo.pwdstore.utils.commitChange import com.zeapo.pwdstore.utils.contains import com.zeapo.pwdstore.utils.getString @@ -753,6 +755,17 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) { !newCategory.isInsideRepository() -> renameCategory(oldCategory, CategoryRenameError.DestinationOutsideRepo) else -> lifecycleScope.launch(Dispatchers.IO) { moveFile(oldCategory.file, newCategory) + + //associate the new category with the last category's timestamp in history + val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) + val timestamp = preference.getString(oldCategory.file.absolutePath.base64()) + if (timestamp != null) { + preference.edit { + remove(oldCategory.file.absolutePath.base64()) + putString(newCategory.absolutePath.base64(), timestamp) + } + } + withContext(Dispatchers.Main) { commitChange( resources.getString(R.string.git_commit_move_text, oldCategory.name, newCategory.name), diff --git a/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt b/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt index 7ffa71a3..0509a3dd 100644 --- a/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt +++ b/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt @@ -96,6 +96,7 @@ private fun PasswordItem.Companion.makeComparator( // declare them all equal at this stage. PasswordRepository.PasswordSortOrder.INDEPENDENT -> Comparator<PasswordItem> { _, _ -> 0 } PasswordRepository.PasswordSortOrder.FILE_FIRST -> compareByDescending { it.type } + PasswordRepository.PasswordSortOrder.RECENTLY_USED -> PasswordRepository.PasswordSortOrder.RECENTLY_USED.comparator } .then(compareBy(nullsLast(CaseInsensitiveComparator)) { directoryStructure.getIdentifierFor(it.file) diff --git a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt index 3b73d6e5..8f306f6b 100644 --- a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt +++ b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt @@ -279,7 +279,8 @@ class UserPreference : AppCompatActivity() { findPreference<CheckBoxPreference>(PreferenceKeys.ENABLE_DEBUG_LOGGING)?.isVisible = !BuildConfig.ENABLE_DEBUG_FEATURES findPreference<CheckBoxPreference>(PreferenceKeys.BIOMETRIC_AUTH)?.apply { - val isFingerprintSupported = BiometricManager.from(requireContext()).canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS + val isFingerprintSupported = BiometricManager.from(requireContext()) + .canAuthenticate(BiometricManager.Authenticators.DEVICE_CREDENTIAL) == BiometricManager.BIOMETRIC_SUCCESS if (!isFingerprintSupported) { isEnabled = false isChecked = false diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FeatureAndTrustDetection.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FeatureAndTrustDetection.kt index 5c8b6b3b..38bdd068 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FeatureAndTrustDetection.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FeatureAndTrustDetection.kt @@ -65,6 +65,7 @@ private val TRUSTED_BROWSER_CERTIFICATE_HASH = mapOf( "com.opera.mini.native" to "V6y8Ul8bLr0ZGWzW8BQ5fMkQ/RiEHgroUP68Ph5ZP/I=", "com.opera.mini.native.beta" to "V6y8Ul8bLr0ZGWzW8BQ5fMkQ/RiEHgroUP68Ph5ZP/I=", "com.opera.touch" to "qtjiBNJNF3k0yc0MY8xqo4779CxKaVcJfiIQ9X+qZ6o=", + "org.bromite.bromite" to "4e5c0HbXsNyEyytF+3i4bfLrOaO2xWuj3CkqXgw7lQQ=", "org.gnu.icecat" to "wi2iuVvK/WYZUzd2g0Qzn9ef3kAisQURZ8U1WSMTkcM=", "org.mozilla.fenix" to "UAR3kIjn+YjVvFzF+HmP6/T4zQhKGypG79TI7krq8hE=", "org.mozilla.fenix.nightly" to "d+rEzu02r++6dheZMd1MwZWrDNVLrzVdIV57vdKOQCo=", @@ -75,6 +76,8 @@ private val TRUSTED_BROWSER_CERTIFICATE_HASH = mapOf( "org.mozilla.focus" to "YgOkc7421k7jf4f6UA7bx56rkwYQq5ufpMp9XB8bT/w=", "org.mozilla.klar" to "YgOkc7421k7jf4f6UA7bx56rkwYQq5ufpMp9XB8bT/w=", "org.torproject.torbrowser" to "IAYfBF5zfGc3XBd5TP7bQ2oDzsa6y3y5+WZCIFyizsg=", + "org.ungoogled.chromium.stable" to "29UOO5cXoxO/e/hH3hOu6bbtg1My4tK6Eik2Ym5Krtk=", + "org.ungoogled.chromium.extensions.stable" to "29UOO5cXoxO/e/hH3hOu6bbtg1My4tK6Eik2Ym5Krtk=", ) private fun isTrustedBrowser(context: Context, appPackage: String): Boolean { @@ -163,6 +166,8 @@ private val FLAKY_BROWSERS = listOf( "com.chrome.beta", "com.chrome.canary", "com.chrome.dev", + "org.bromite.bromite", + "org.ungoogled.chromium.stable", ) enum class BrowserAutofillSupportLevel { 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 44376c0a..ff5a8179 100644 --- a/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt @@ -5,6 +5,7 @@ package com.zeapo.pwdstore.crypto +import android.content.Context import android.content.Intent import android.os.Bundle import android.text.InputType @@ -14,6 +15,7 @@ import android.view.View import androidx.activity.result.IntentSenderRequest import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult +import androidx.core.content.edit import androidx.core.view.isVisible import androidx.core.widget.doOnTextChanged import androidx.lifecycle.lifecycleScope @@ -30,6 +32,7 @@ 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.base64 import com.zeapo.pwdstore.utils.commitChange import com.zeapo.pwdstore.utils.getString import com.zeapo.pwdstore.utils.isInsideRepository @@ -411,6 +414,17 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB return@executeApiAsync } + //associate the new password name with the last name's timestamp in history + val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) + val oldFilePathHash = "$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg".base64() + val timestamp = preference.getString(oldFilePathHash) + if (timestamp != null) { + preference.edit { + remove(oldFilePathHash) + putString(file.absolutePath.base64(), timestamp) + } + } + val returnIntent = Intent() returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path) returnIntent.putExtra(RETURN_EXTRA_NAME, editName) 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 fb0831d6..21795404 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt @@ -4,17 +4,12 @@ */ package com.zeapo.pwdstore.git -import android.content.Intent import android.view.MenuItem -import androidx.annotation.CallSuper import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.lifecycleScope import com.github.ajalt.timberkt.Timber.tag import com.github.ajalt.timberkt.e import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zeapo.pwdstore.git.config.ConnectionMode import com.zeapo.pwdstore.git.config.GitSettings -import com.zeapo.pwdstore.git.config.SshApiSessionFactory import com.zeapo.pwdstore.git.operation.BreakOutOfDetached import com.zeapo.pwdstore.git.operation.CloneOperation import com.zeapo.pwdstore.git.operation.GitOperation @@ -23,7 +18,6 @@ import com.zeapo.pwdstore.git.operation.PushOperation import com.zeapo.pwdstore.git.operation.ResetToRemoteOperation import com.zeapo.pwdstore.git.operation.SyncOperation import com.zeapo.pwdstore.utils.PasswordRepository -import kotlinx.coroutines.launch /** * Abstract AppCompatActivity that holds some information that is commonly shared across git-related @@ -31,9 +25,6 @@ import kotlinx.coroutines.launch */ abstract class BaseGitActivity : AppCompatActivity() { - private var identityBuilder: SshApiSessionFactory.IdentityBuilder? = null - private var identity: SshApiSessionFactory.ApiIdentity? = null - override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { android.R.id.home -> { @@ -44,23 +35,8 @@ abstract class BaseGitActivity : AppCompatActivity() { } } - @CallSuper - override fun onDestroy() { - // Do not leak the service connection - if (identityBuilder != null) { - identityBuilder!!.close() - identityBuilder = null - } - super.onDestroy() - } - /** - * Attempt to launch the requested Git operation. Depending on the configured auth, it may not - * be possible to launch the operation immediately. In that case, this function may launch an - * intermediate activity instead, which will gather necessary information and post it back via - * onActivityResult, which will then re-call this function. This may happen multiple times, - * until either an error is encountered or the operation is successfully launched. - * + * Attempt to launch the requested Git operation. * @param operation The type of git operation to launch */ suspend fun launchGitOperation(operation: Int) { @@ -70,21 +46,6 @@ abstract class BaseGitActivity : AppCompatActivity() { return } try { - // Before launching the operation with OpenKeychain auth, we need to issue several requests - // to the OpenKeychain API. IdentityBuild will take care of launching the relevant intents, - // we just need to keep calling it until it returns a completed ApiIdentity. - if (GitSettings.connectionMode == ConnectionMode.OpenKeychain && identity == null) { - // Lazy initialization of the IdentityBuilder - if (identityBuilder == null) { - identityBuilder = SshApiSessionFactory.IdentityBuilder(this) - } - // Try to get an ApiIdentity and bail if one is not ready yet. The builder will ensure - // that onActivityResult is called with operation again, which will re-invoke us here - identity = identityBuilder!!.tryBuild(operation) - if (identity == null) - return - } - val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory()) val op = when (operation) { REQUEST_CLONE, GitOperation.GET_SSH_KEY_FROM_CLONE -> CloneOperation(localDir, GitSettings.url!!, this) @@ -93,7 +54,6 @@ abstract class BaseGitActivity : AppCompatActivity() { REQUEST_SYNC -> SyncOperation(localDir, this) BREAK_OUT_OF_DETACHED -> BreakOutOfDetached(localDir, this) REQUEST_RESET -> ResetToRemoteOperation(localDir, this) - SshApiSessionFactory.POST_SIGNATURE -> return else -> { tag(TAG).e { "Operation not recognized : $operation" } setResult(RESULT_CANCELED) @@ -101,46 +61,13 @@ abstract class BaseGitActivity : AppCompatActivity() { return } } - op.executeAfterAuthentication(GitSettings.connectionMode, identity) + op.executeAfterAuthentication(GitSettings.connectionMode) } catch (e: Exception) { e.printStackTrace() MaterialAlertDialogBuilder(this).setMessage(e.message).show() } } - public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - // In addition to the pre-operation-launch series of intents for OpenKeychain auth - // that will pass through here and back to launchGitOperation, there is one - // synchronous operation that happens /after/ the operation has been launched in the - // background thread - the actual signing of the SSH challenge. We pass through the - // completed signature to the ApiIdentity, which will be blocked in the other thread - // waiting for it. - if (requestCode == SshApiSessionFactory.POST_SIGNATURE && identity != null) { - identity!!.postSignature(data) - - // If the signature failed (usually because it was cancelled), reset state - if (data == null) { - identity = null - identityBuilder = null - } - return - } - - if (resultCode == RESULT_CANCELED) { - setResult(RESULT_CANCELED) - finish() - } else if (resultCode == RESULT_OK) { - // If an operation has been re-queued via this mechanism, let the - // IdentityBuilder attempt to extract some updated state from the intent before - // trying to re-launch the operation. - if (identityBuilder != null) { - identityBuilder!!.consume(data) - } - lifecycleScope.launch { launchGitOperation(requestCode) } - } - super.onActivityResult(requestCode, resultCode, data) - } - companion object { const val REQUEST_ARG_OP = "OPERATION" diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt index f06fc891..bf35e5c7 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt @@ -14,7 +14,8 @@ import com.google.android.material.snackbar.Snackbar import com.zeapo.pwdstore.R import com.zeapo.pwdstore.git.GitException.PullException import com.zeapo.pwdstore.git.GitException.PushException -import com.zeapo.pwdstore.git.config.SshjSessionFactory +import com.zeapo.pwdstore.git.config.GitSettings +import com.zeapo.pwdstore.git.sshj.SshjSessionFactory import com.zeapo.pwdstore.git.operation.GitOperation import com.zeapo.pwdstore.utils.Result import com.zeapo.pwdstore.utils.snackbar @@ -28,6 +29,7 @@ import org.eclipse.jgit.api.PullCommand import org.eclipse.jgit.api.PushCommand import org.eclipse.jgit.api.RebaseResult import org.eclipse.jgit.api.StatusCommand +import org.eclipse.jgit.lib.PersonIdent import org.eclipse.jgit.transport.RemoteRefUpdate import org.eclipse.jgit.transport.SshSessionFactory @@ -60,7 +62,9 @@ class GitCommandExecutor( // the previous status will eventually be used to avoid a commit if (nbChanges > 0) { withContext(Dispatchers.IO) { - command.call() + command + .setAuthor(PersonIdent(GitSettings.authorName, GitSettings.authorEmail)) + .call() } } } 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 5138b50b..9205e7fc 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt @@ -61,8 +61,6 @@ class GitConfigActivity : BaseGitActivity() { } else { GitSettings.authorEmail = email GitSettings.authorName = name - PasswordRepository.setGitAuthorEmail(email) - PasswordRepository.setGitAuthorName(name) Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show() Handler().postDelayed(500) { finish() } } 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 deleted file mode 100644 index 03760741..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/config/SshApiSessionFactory.java +++ /dev/null @@ -1,383 +0,0 @@ -/* - * 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 android.app.PendingIntent; -import android.content.Intent; -import android.content.IntentSender; -import android.content.SharedPreferences; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AppCompatActivity; -import androidx.preference.PreferenceManager; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.jcraft.jsch.Identity; -import com.jcraft.jsch.JSch; -import com.jcraft.jsch.JSchException; -import com.jcraft.jsch.Session; -import com.zeapo.pwdstore.R; -import com.zeapo.pwdstore.git.BaseGitActivity; -import com.zeapo.pwdstore.utils.PreferenceKeys; - -import org.eclipse.jgit.transport.JschConfigSessionFactory; -import org.eclipse.jgit.transport.OpenSshConfig; -import org.eclipse.jgit.util.Base64; -import org.eclipse.jgit.util.FS; -import org.openintents.ssh.authentication.ISshAuthenticationService; -import org.openintents.ssh.authentication.SshAuthenticationApi; -import org.openintents.ssh.authentication.SshAuthenticationApiError; -import org.openintents.ssh.authentication.SshAuthenticationConnection; -import org.openintents.ssh.authentication.request.KeySelectionRequest; -import org.openintents.ssh.authentication.request.Request; -import org.openintents.ssh.authentication.request.SigningRequest; -import org.openintents.ssh.authentication.request.SshPublicKeyRequest; -import org.openintents.ssh.authentication.util.SshAuthenticationApiUtils; - -import java.util.List; -import java.util.concurrent.CountDownLatch; - -public class SshApiSessionFactory extends JschConfigSessionFactory { - /** - * Intent request code indicating a completed signature that should be posted to an outstanding - * ApiIdentity - */ - public static final int POST_SIGNATURE = 301; - - private final Identity identity; - - public SshApiSessionFactory(Identity identity) { - this.identity = identity; - } - - @NonNull - @Override - protected JSch getJSch(@NonNull final OpenSshConfig.Host hc, @NonNull FS fs) - throws JSchException { - JSch jsch = super.getJSch(hc, fs); - jsch.removeAllIdentity(); - jsch.addIdentity(identity, null); - return jsch; - } - - @Override - protected void configure(@NonNull OpenSshConfig.Host hc, Session session) { - session.setConfig("StrictHostKeyChecking", "no"); - session.setConfig("PreferredAuthentications", "publickey"); - } - - /** - * Helper to build up an ApiIdentity via the invocation of several pending intents that - * communicate with OpenKeychain. The user of this class must handle onActivityResult and keep - * feeding the resulting intents into the IdentityBuilder until it can successfully complete the - * build. - */ - public static class IdentityBuilder { - private final SshAuthenticationConnection connection; - private final BaseGitActivity callingActivity; - private final SharedPreferences settings; - private SshAuthenticationApi api; - private String keyId, description, alg; - private byte[] publicKey; - - /** - * Construct a new IdentityBuilder - * - * @param callingActivity Activity that will be used to launch pending intents and that will - * receive and handle the results. - */ - public IdentityBuilder(BaseGitActivity callingActivity) { - this.callingActivity = callingActivity; - - List<String> providers = - SshAuthenticationApiUtils.getAuthenticationProviderPackageNames( - callingActivity); - if (providers.isEmpty()) - throw new RuntimeException(callingActivity.getString(R.string.no_ssh_api_provider)); - - // TODO: Handle multiple available providers? Are there actually any in practice beyond - // OpenKeychain? - connection = new SshAuthenticationConnection(callingActivity, providers.get(0)); - - settings = - PreferenceManager.getDefaultSharedPreferences( - callingActivity.getApplicationContext()); - keyId = settings.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null); - } - - /** - * Free any resources associated with this IdentityBuilder - */ - public void close() { - if (connection != null && connection.isConnected()) connection.disconnect(); - } - - /** - * Helper to invoke an OpenKeyshain SSH API method and correctly interpret the result. - * - * @param request The request intent to launch - * @param requestCode The request code to use if a pending intent needs to be sent - * @return The resulting intent if the request completed immediately, or null if we had to - * launch a pending intent to interact with the user - */ - private Intent executeApi(Request request, int requestCode) { - Intent result = api.executeApi(request.toIntent()); - - switch (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, -1)) { - case SshAuthenticationApi.RESULT_CODE_ERROR: - SshAuthenticationApiError error = - result.getParcelableExtra(SshAuthenticationApi.EXTRA_ERROR); - // On an OpenKeychain SSH API error, clear out the stored keyid - 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 - // different one - case (SshAuthenticationApiError.NO_SUCH_KEY): - case (SshAuthenticationApiError.NO_AUTH_KEY): - keyId = null; - publicKey = null; - description = null; - alg = null; - return executeApi(new KeySelectionRequest(), requestCode); - - // Other errors are fatal - default: - throw new RuntimeException(error.getMessage()); - } - case SshAuthenticationApi.RESULT_CODE_SUCCESS: - break; - case SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED: - PendingIntent pendingIntent = - result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT); - try { - callingActivity.startIntentSenderForResult( - pendingIntent.getIntentSender(), requestCode, null, 0, 0, 0); - return null; - } catch (IntentSender.SendIntentException e) { - e.printStackTrace(); - throw new RuntimeException( - callingActivity.getString(R.string.ssh_api_pending_intent_failed)); - } - default: - throw new RuntimeException( - callingActivity.getString(R.string.ssh_api_unknown_error)); - } - - return result; - } - - /** - * Parse a given intent to see if it is the result of an OpenKeychain pending intent. If so, - * extract any updated state from it. - * - * @param intent The intent to inspect - */ - public void consume(Intent intent) { - if (intent == null) return; - - if (intent.hasExtra(SshAuthenticationApi.EXTRA_KEY_ID)) { - keyId = intent.getStringExtra(SshAuthenticationApi.EXTRA_KEY_ID); - description = intent.getStringExtra(SshAuthenticationApi.EXTRA_KEY_DESCRIPTION); - settings.edit().putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, keyId).apply(); - } - - if (intent.hasExtra(SshAuthenticationApi.EXTRA_SSH_PUBLIC_KEY)) { - String keyStr = intent.getStringExtra(SshAuthenticationApi.EXTRA_SSH_PUBLIC_KEY); - String[] keyParts = keyStr.split(" "); - alg = keyParts[0]; - publicKey = Base64.decode(keyParts[1]); - } - } - - /** - * Try to build an ApiIdentity that will perform SSH authentication via OpenKeychain. - * - * @param requestCode The request code to use if a pending intent needs to be sent - * @return The built identity, or null of user interaction is still required (in which case - * a pending intent will have already been launched) - */ - public ApiIdentity tryBuild(int requestCode) { - // First gate, need to initiate a connection to the service and wait for it to connect. - if (api == null) { - connection.connect( - new SshAuthenticationConnection.OnBound() { - @Override - public void onBound(ISshAuthenticationService sshAgent) { - api = new SshAuthenticationApi(callingActivity, sshAgent); - // We can immediately try the next phase without needing to post - // back - // though onActivityResult - callingActivity.onActivityResult( - requestCode, AppCompatActivity.RESULT_OK, null); - } - - @Override - public void onError() { - new MaterialAlertDialogBuilder(callingActivity) - .setMessage( - callingActivity.getString( - R.string.openkeychain_ssh_api_connect_fail)) - .show(); - } - }); - - return null; - } - - // Second gate, need the user to select which key they want to use - if (keyId == null) { - consume(executeApi(new KeySelectionRequest(), requestCode)); - // If we did not immediately get the result, bail for now and wait to be re-entered - if (keyId == null) return null; - } - - // Third gate, need to get the public key for the selected key. This one often does not - // need use interaction. - if (publicKey == null) { - consume(executeApi(new SshPublicKeyRequest(keyId), requestCode)); - // If we did not immediately get the result, bail for now and wait to be re-entered - if (publicKey == null) return null; - } - - // Have everything we need for now, build the identify - return new ApiIdentity(keyId, description, publicKey, alg, callingActivity, api); - } - } - - /** - * A Jsch identity that delegates key operations via the OpenKeychain SSH API - */ - public static class ApiIdentity implements Identity { - private final String keyId; - private final String description; - private final String alg; - private final byte[] publicKey; - private final AppCompatActivity callingActivity; - private final SshAuthenticationApi api; - private CountDownLatch latch; - private byte[] signature; - - ApiIdentity( - String keyId, - String description, - byte[] publicKey, - String alg, - AppCompatActivity callingActivity, - SshAuthenticationApi api) { - this.keyId = keyId; - this.description = description; - this.publicKey = publicKey; - this.alg = alg; - this.callingActivity = callingActivity; - this.api = api; - } - - @Override - public boolean setPassphrase(byte[] passphrase) { - // We are not encrypted with a passphrase - return true; - } - - @Override - public byte[] getPublicKeyBlob() { - return publicKey; - } - - /** - * Helper to handle the result of an OpenKeyshain SSH API signing request - * - * @param result The result intent to handle - * @return The signed challenge, or null if it was not immediately available, in which case - * the latch has been initialized and the pending intent started - */ - private byte[] handleSignResult(Intent result) { - switch (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, -1)) { - case SshAuthenticationApi.RESULT_CODE_ERROR: - SshAuthenticationApiError error = - result.getParcelableExtra(SshAuthenticationApi.EXTRA_ERROR); - throw new RuntimeException(error.getMessage()); - case SshAuthenticationApi.RESULT_CODE_SUCCESS: - return result.getByteArrayExtra(SshAuthenticationApi.EXTRA_SIGNATURE); - case SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED: - PendingIntent pendingIntent = - result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT); - try { - latch = new CountDownLatch(1); - callingActivity.startIntentSenderForResult( - pendingIntent.getIntentSender(), POST_SIGNATURE, null, 0, 0, 0); - return null; - - } catch (Exception e) { - e.printStackTrace(); - throw new RuntimeException( - callingActivity.getString(R.string.ssh_api_pending_intent_failed)); - } - default: - if (result.hasExtra(SshAuthenticationApi.EXTRA_CHALLENGE)) - return handleSignResult(api.executeApi(result)); - throw new RuntimeException( - callingActivity.getString(R.string.ssh_api_unknown_error)); - } - } - - @Override - public byte[] getSignature(byte[] data) { - Intent request = new SigningRequest(data, keyId, SshAuthenticationApi.SHA1).toIntent(); - signature = handleSignResult(api.executeApi(request)); - - // If we did not immediately get a signature (probable), we will block on a latch until - // the main activity gets the intent result and posts to us. - if (signature == null) { - try { - latch.await(); - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - - return signature; - } - - /** - * Post a signature response back to an in-progress operation using this ApiIdentity. - * - * @param data The signature data (hopefully) - */ - public void postSignature(Intent data) { - try { - if (data != null) { - signature = handleSignResult(data); - } - } finally { - if (latch != null) latch.countDown(); - } - } - - @Override - public boolean decrypt() { - return true; - } - - @Override - public String getAlgName() { - return alg; - } - - @Override - public String getName() { - return description; - } - - @Override - public boolean isEncrypted() { - return false; - } - - @Override - public void clear() { - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/CredentialFinder.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/CredentialFinder.kt index 423ceb80..6b6b8ea8 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/operation/CredentialFinder.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/operation/CredentialFinder.kt @@ -11,7 +11,7 @@ import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout import com.zeapo.pwdstore.R import com.zeapo.pwdstore.git.config.ConnectionMode -import com.zeapo.pwdstore.git.config.InteractivePasswordFinder +import com.zeapo.pwdstore.git.sshj.InteractivePasswordFinder import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.getEncryptedPrefs import com.zeapo.pwdstore.utils.requestInputFocusOnView diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt index 62d6879c..3efd3bd8 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt @@ -8,7 +8,6 @@ import android.content.Intent import androidx.annotation.CallSuper import androidx.core.content.edit import androidx.fragment.app.FragmentActivity -import androidx.preference.PreferenceManager import com.github.ajalt.timberkt.Timber.d import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.zeapo.pwdstore.R @@ -16,13 +15,13 @@ import com.zeapo.pwdstore.UserPreference import com.zeapo.pwdstore.git.ErrorMessages import com.zeapo.pwdstore.git.config.ConnectionMode import com.zeapo.pwdstore.git.config.GitSettings -import com.zeapo.pwdstore.git.config.InteractivePasswordFinder -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.git.sshj.InteractivePasswordFinder +import com.zeapo.pwdstore.git.sshj.SshAuthData +import com.zeapo.pwdstore.git.sshj.SshjSessionFactory import com.zeapo.pwdstore.utils.PasswordRepository import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.getEncryptedPrefs +import com.zeapo.pwdstore.utils.sharedPrefs import java.io.File import net.schmizz.sshj.userauth.password.PasswordFinder import org.eclipse.jgit.api.Git @@ -85,8 +84,9 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: Fragment return this } - private fun withOpenKeychainAuthentication(identity: SshApiSessionFactory.ApiIdentity?): GitOperation { - SshSessionFactory.setInstance(SshApiSessionFactory(identity)) + private fun withOpenKeychainAuthentication(activity: FragmentActivity): GitOperation { + val sessionFactory = SshjSessionFactory(SshAuthData.OpenKeychain(activity), hostKeyFile) + SshSessionFactory.setInstance(sessionFactory) this.provider = null return this } @@ -117,7 +117,6 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: Fragment suspend fun executeAfterAuthentication( connectionMode: ConnectionMode, - identity: SshApiSessionFactory.ApiIdentity? ) { when (connectionMode) { ConnectionMode.SshKey -> if (!sshKeyFile.exists()) { @@ -138,7 +137,7 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: Fragment withPublicKeyAuthentication( CredentialFinder(callingActivity, connectionMode)).execute() } - ConnectionMode.OpenKeychain -> withOpenKeychainAuthentication(identity).execute() + ConnectionMode.OpenKeychain -> withOpenKeychainAuthentication(callingActivity).execute() ConnectionMode.Password -> withPasswordAuthentication( CredentialFinder(callingActivity, connectionMode)).execute() ConnectionMode.None -> execute() @@ -151,17 +150,10 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: Fragment @CallSuper open fun onError(err: Exception) { // Clear various auth related fields on failure - when (SshSessionFactory.getInstance()) { - is SshApiSessionFactory -> { - PreferenceManager.getDefaultSharedPreferences(callingActivity.applicationContext) - .edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) } - } - is SshjSessionFactory -> { - callingActivity.getEncryptedPrefs("git_operation").edit { - remove(PreferenceKeys.HTTPS_PASSWORD) - } - } + callingActivity.getEncryptedPrefs("git_operation").edit { + remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) } + callingActivity.sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) } d(err) MaterialAlertDialogBuilder(callingActivity) .setTitle(callingActivity.resources.getString(R.string.jgit_error_dialog_title)) diff --git a/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainKeyProvider.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainKeyProvider.kt new file mode 100644 index 00000000..cecf7505 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainKeyProvider.kt @@ -0,0 +1,212 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.git.sshj + +import android.app.PendingIntent +import android.content.Intent +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.edit +import androidx.fragment.app.FragmentActivity +import com.github.ajalt.timberkt.d +import com.zeapo.pwdstore.utils.OPENPGP_PROVIDER +import com.zeapo.pwdstore.utils.PreferenceKeys +import com.zeapo.pwdstore.utils.sharedPrefs +import java.io.Closeable +import java.security.PublicKey +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.schmizz.sshj.common.Base64 +import net.schmizz.sshj.common.Buffer +import net.schmizz.sshj.common.DisconnectReason +import net.schmizz.sshj.common.KeyType +import net.schmizz.sshj.userauth.UserAuthException +import net.schmizz.sshj.userauth.keyprovider.KeyProvider +import org.openintents.ssh.authentication.ISshAuthenticationService +import org.openintents.ssh.authentication.SshAuthenticationApi +import org.openintents.ssh.authentication.SshAuthenticationApiError +import org.openintents.ssh.authentication.SshAuthenticationConnection +import org.openintents.ssh.authentication.request.KeySelectionRequest +import org.openintents.ssh.authentication.request.Request +import org.openintents.ssh.authentication.request.SigningRequest +import org.openintents.ssh.authentication.request.SshPublicKeyRequest +import org.openintents.ssh.authentication.response.KeySelectionResponse +import org.openintents.ssh.authentication.response.Response +import org.openintents.ssh.authentication.response.SigningResponse +import org.openintents.ssh.authentication.response.SshPublicKeyResponse + +class OpenKeychainKeyProvider private constructor(private val activity: FragmentActivity) : KeyProvider, Closeable { + + companion object { + + suspend fun prepareAndUse(activity: FragmentActivity, block: (provider: OpenKeychainKeyProvider) -> Unit) { + withContext(Dispatchers.Main){ + OpenKeychainKeyProvider(activity) + }.prepareAndUse(block) + } + } + + private sealed class ApiResponse { + data class Success(val response: Response) : ApiResponse() + data class GeneralError(val exception: Exception) : ApiResponse() + data class NoSuchKey(val exception: Exception) : ApiResponse() + } + + private val context = activity.applicationContext + private val sshServiceConnection = SshAuthenticationConnection(context, OPENPGP_PROVIDER) + private val preferences = context.sharedPrefs + private val continueAfterUserInteraction = + activity.registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> + currentCont?.let { cont -> + currentCont = null + val data = result.data + if (data != null) + cont.resume(data) + else + cont.resumeWithException(UserAuthException(DisconnectReason.AUTH_CANCELLED_BY_USER)) + } + } + + private lateinit var sshServiceApi: SshAuthenticationApi + + private var currentCont: Continuation<Intent>? = null + private var keyId + get() = preferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) + set(value) { + preferences.edit { + putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, value) + } + } + private var publicKey: PublicKey? = null + private var privateKey: OpenKeychainPrivateKey? = null + + private suspend fun prepareAndUse(block: (provider: OpenKeychainKeyProvider) -> Unit) { + prepare() + use(block) + } + + private suspend fun prepare() { + sshServiceApi = suspendCoroutine { cont -> + sshServiceConnection.connect(object : SshAuthenticationConnection.OnBound { + override fun onBound(sshAgent: ISshAuthenticationService) { + d { "Bound to SshAuthenticationApi: $OPENPGP_PROVIDER" } + cont.resume(SshAuthenticationApi(context, sshAgent)) + } + + override fun onError() { + throw UserAuthException(DisconnectReason.UNKNOWN, "OpenKeychain service unavailable") + } + }) + } + + if (keyId == null) { + selectKey() + } + check(keyId != null) + fetchPublicKey() + makePrivateKey() + } + + private suspend fun fetchPublicKey(isRetry: Boolean = false) { + when (val sshPublicKeyResponse = executeApiRequest(SshPublicKeyRequest(keyId))) { + is ApiResponse.Success -> { + val response = sshPublicKeyResponse.response as SshPublicKeyResponse + val sshPublicKey = response.sshPublicKey!! + val sshKeyParts = sshPublicKey.split("""\s+""".toRegex()) + check(sshKeyParts.size >= 2) { "OpenKeychain API returned invalid SSH key" } + @Suppress("BlockingMethodInNonBlockingContext") + publicKey = Buffer.PlainBuffer(Base64.decode(sshKeyParts[1])).readPublicKey() + } + is ApiResponse.NoSuchKey -> if (isRetry) { + throw sshPublicKeyResponse.exception + } else { + // Allow the user to reselect an authentication key and retry + selectKey() + fetchPublicKey(true) + } + is ApiResponse.GeneralError -> throw sshPublicKeyResponse.exception + } + } + + private suspend fun selectKey() { + when (val keySelectionResponse = executeApiRequest(KeySelectionRequest())) { + is ApiResponse.Success -> keyId = (keySelectionResponse.response as KeySelectionResponse).keyId + is ApiResponse.GeneralError -> throw keySelectionResponse.exception + is ApiResponse.NoSuchKey -> throw keySelectionResponse.exception + } + } + + private suspend fun executeApiRequest(request: Request, resultOfUserInteraction: Intent? = null): ApiResponse { + d { "executeRequest($request) called" } + val result = withContext(Dispatchers.Main) { + // If the request required user interaction, the data returned from the PendingIntent + // is used as the real request. + sshServiceApi.executeApi(resultOfUserInteraction ?: request.toIntent())!! + } + return parseResult(request, result).also { + d { "executeRequest($request): $it" } + } + } + + private suspend fun parseResult(request: Request, result: Intent): ApiResponse { + return when (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, SshAuthenticationApi.RESULT_CODE_ERROR)) { + SshAuthenticationApi.RESULT_CODE_SUCCESS -> { + ApiResponse.Success(when (request) { + is KeySelectionRequest -> KeySelectionResponse(result) + is SshPublicKeyRequest -> SshPublicKeyResponse(result) + is SigningRequest -> SigningResponse(result) + else -> throw IllegalArgumentException("Unsupported OpenKeychain request type") + }) + } + SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val pendingIntent: PendingIntent = result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT)!! + val resultOfUserInteraction: Intent = withContext(Dispatchers.Main) { + suspendCoroutine { cont -> + currentCont = cont + continueAfterUserInteraction.launch(IntentSenderRequest.Builder(pendingIntent).build()) + } + } + executeApiRequest(request, resultOfUserInteraction) + } + else -> { + val error = result.getParcelableExtra<SshAuthenticationApiError>(SshAuthenticationApi.EXTRA_ERROR) + val exception = UserAuthException(DisconnectReason.UNKNOWN, "Request ${request::class.simpleName} failed: ${error?.message}") + when (error?.error) { + SshAuthenticationApiError.NO_AUTH_KEY, SshAuthenticationApiError.NO_SUCH_KEY -> ApiResponse.NoSuchKey(exception) + else -> ApiResponse.GeneralError(exception) + } + } + } + } + + private fun makePrivateKey() { + check(keyId != null && publicKey != null) + privateKey = object : OpenKeychainPrivateKey { + override suspend fun sign(challenge: ByteArray, hashAlgorithm: Int) = + when (val signingResponse = executeApiRequest(SigningRequest(challenge, keyId, hashAlgorithm))) { + is ApiResponse.Success -> (signingResponse.response as SigningResponse).signature + is ApiResponse.GeneralError -> throw signingResponse.exception + is ApiResponse.NoSuchKey -> throw signingResponse.exception + } + + override fun getAlgorithm() = publicKey!!.algorithm + } + } + + override fun close() { + continueAfterUserInteraction.unregister() + sshServiceConnection.disconnect() + } + + override fun getPrivate() = privateKey + + override fun getPublic() = publicKey + + override fun getType() = KeyType.fromKey(publicKey) +} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt new file mode 100644 index 00000000..97b587fd --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt @@ -0,0 +1,93 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.git.sshj + +import com.hierynomus.sshj.key.KeyAlgorithm +import java.io.ByteArrayOutputStream +import java.security.PrivateKey +import kotlinx.coroutines.runBlocking +import net.schmizz.sshj.common.Buffer +import net.schmizz.sshj.common.Factory +import net.schmizz.sshj.signature.Signature +import org.openintents.ssh.authentication.SshAuthenticationApi + +interface OpenKeychainPrivateKey : PrivateKey { + + suspend fun sign(challenge: ByteArray, hashAlgorithm: Int): ByteArray + + override fun getFormat() = null + override fun getEncoded() = null +} + +class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named<KeyAlgorithm>) : Factory.Named<KeyAlgorithm> by factory { + + override fun create() = OpenKeychainWrappedKeyAlgorithm(factory.create()) +} + +class OpenKeychainWrappedKeyAlgorithm(private val keyAlgorithm: KeyAlgorithm) : KeyAlgorithm by keyAlgorithm { + + private val hashAlgorithm = when (keyAlgorithm.keyAlgorithm) { + "rsa-sha2-512" -> SshAuthenticationApi.SHA512 + "rsa-sha2-256" -> SshAuthenticationApi.SHA256 + "ssh-rsa", "ssh-rsa-cert-v01@openssh.com" -> SshAuthenticationApi.SHA1 + // Other algorithms don't use this value, but it has to be valid. + else -> SshAuthenticationApi.SHA512 + } + + override fun newSignature() = OpenKeychainWrappedSignature(keyAlgorithm.newSignature(), hashAlgorithm) +} + +class OpenKeychainWrappedSignature(private val wrappedSignature: Signature, private val hashAlgorithm: Int) : Signature by wrappedSignature { + + private val data = ByteArrayOutputStream() + + private var bridgedPrivateKey: OpenKeychainPrivateKey? = null + + override fun initSign(prvkey: PrivateKey?) { + if (prvkey is OpenKeychainPrivateKey) { + bridgedPrivateKey = prvkey + } else { + wrappedSignature.initSign(prvkey) + } + } + + override fun update(H: ByteArray?) { + if (bridgedPrivateKey != null) { + data.write(H!!) + } else { + wrappedSignature.update(H) + } + } + + override fun update(H: ByteArray?, off: Int, len: Int) { + if (bridgedPrivateKey != null) { + data.write(H!!, off, len) + } else { + wrappedSignature.update(H, off, len) + } + } + + override fun sign(): ByteArray? = if (bridgedPrivateKey != null) { + runBlocking { + bridgedPrivateKey!!.sign(data.toByteArray(), hashAlgorithm) + } + } else { + wrappedSignature.sign() + } + + override fun encode(signature: ByteArray?): ByteArray? = if (bridgedPrivateKey != null) { + require(signature != null) { "OpenKeychain signature must not be null" } + val encodedSignature = Buffer.PlainBuffer(signature) + // We need to drop the algorithm name and extract the raw signature since SSHJ adds the name + // later. + encodedSignature.readString() + encodedSignature.readBytes().also { + bridgedPrivateKey = null + data.reset() + } + } else { + wrappedSignature.encode(signature) + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/config/SshjConfig.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt index 6c409329..bf454cd5 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/config/SshjConfig.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt @@ -2,7 +2,7 @@ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. * SPDX-License-Identifier: GPL-3.0-only */ -package com.zeapo.pwdstore.git.config +package com.zeapo.pwdstore.git.sshj import com.github.ajalt.timberkt.Timber import com.github.ajalt.timberkt.d @@ -232,7 +232,9 @@ class SshjConfig : ConfigImpl() { KeyAlgorithms.ECDSASHANistp384(), KeyAlgorithms.ECDSASHANistp256(), KeyAlgorithms.SSHRSA(), - ) + ).map { + OpenKeychainWrappedKeyAlgorithmFactory(it) + } } private fun initRandomFactory() { diff --git a/app/src/main/java/com/zeapo/pwdstore/git/config/SshjSessionFactory.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjSessionFactory.kt index 85b5f753..05428e41 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/config/SshjSessionFactory.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjSessionFactory.kt @@ -2,9 +2,10 @@ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. * SPDX-License-Identifier: GPL-3.0-only */ -package com.zeapo.pwdstore.git.config +package com.zeapo.pwdstore.git.sshj import android.util.Base64 +import androidx.fragment.app.FragmentActivity import com.github.ajalt.timberkt.d import com.github.ajalt.timberkt.w import java.io.File @@ -37,6 +38,7 @@ import org.eclipse.jgit.util.FS sealed class SshAuthData { class Password(val passwordFinder: InteractivePasswordFinder) : SshAuthData() class PublicKeyFile(val keyFile: File, val passphraseFinder: InteractivePasswordFinder) : SshAuthData() + class OpenKeychain(val activity: FragmentActivity) : SshAuthData() } abstract class InteractivePasswordFinder : PasswordFinder { @@ -128,6 +130,13 @@ private class SshjSession(uri: URIish, private val username: String, private val is SshAuthData.PublicKeyFile -> { ssh.authPublickey(username, ssh.loadKeys(authData.keyFile.absolutePath, authData.passphraseFinder)) } + is SshAuthData.OpenKeychain -> { + runBlocking { + OpenKeychainKeyProvider.prepareAndUse(authData.activity) { provider -> + ssh.authPublickey(username, provider) + } + } + } } return this } diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/BiometricAuthenticator.kt b/app/src/main/java/com/zeapo/pwdstore/utils/BiometricAuthenticator.kt index d29e7ac4..0792c1fc 100644 --- a/app/src/main/java/com/zeapo/pwdstore/utils/BiometricAuthenticator.kt +++ b/app/src/main/java/com/zeapo/pwdstore/utils/BiometricAuthenticator.kt @@ -9,6 +9,7 @@ import android.os.Handler import androidx.annotation.StringRes import androidx.biometric.BiometricConstants import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators import androidx.biometric.BiometricPrompt import androidx.core.content.getSystemService import androidx.fragment.app.FragmentActivity @@ -60,14 +61,15 @@ object BiometricAuthenticator { callback(Result.Success(result.cryptoObject)) } } - val biometricPrompt = BiometricPrompt(activity, { handler.post(it) }, authCallback) - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle(activity.getString(dialogTitleRes)) - .setDeviceCredentialAllowed(true) - .build() - if (BiometricManager.from(activity).canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS || - activity.getSystemService<KeyguardManager>()?.isDeviceSecure == true) { - biometricPrompt.authenticate(promptInfo) + val validAuthenticators = Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK + val canAuth = BiometricManager.from(activity).canAuthenticate(validAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS + val deviceHasKeyguard = activity.getSystemService<KeyguardManager>()?.isDeviceSecure == true + if (canAuth || deviceHasKeyguard) { + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(activity.getString(dialogTitleRes)) + .setAllowedAuthenticators(validAuthenticators) + .build() + BiometricPrompt(activity, { handler.post(it) }, authCallback).authenticate(promptInfo) } else { callback(Result.HardwareUnavailableOrDisabled) } 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 96bf1b7e..561b8d99 100644 --- a/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt +++ b/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt @@ -9,6 +9,7 @@ import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.os.Build +import android.util.Base64 import android.util.TypedValue import android.view.View import android.view.autofill.AutofillManager @@ -42,6 +43,10 @@ fun String.splitLines(): Array<String> { return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() } +fun String.base64(): String { + return Base64.encodeToString(encodeToByteArray(), Base64.NO_WRAP) +} + val Context.clipboard get() = getSystemService<ClipboardManager>() fun FragmentActivity.snackbar( 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 8a49f0e3..f0b891fc 100644 --- a/app/src/main/java/com/zeapo/pwdstore/utils/PasswordRepository.kt +++ b/app/src/main/java/com/zeapo/pwdstore/utils/PasswordRepository.kt @@ -4,6 +4,7 @@ */ package com.zeapo.pwdstore.utils +import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit import com.zeapo.pwdstore.Application @@ -31,6 +32,18 @@ open class PasswordRepository protected constructor() { p1.name.compareTo(p2.name, ignoreCase = true) }), + RECENTLY_USED(Comparator { p1: PasswordItem, p2: PasswordItem -> + val recentHistory = Application.instance.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) + val timeP1 = recentHistory.getString(p1.file.absolutePath.base64()) + val timeP2 = recentHistory.getString(p2.file.absolutePath.base64()) + when { + timeP1 != null && timeP2 != null -> timeP2.compareTo(timeP1) + timeP1 != null && timeP2 == null -> return@Comparator -1 + timeP1 == null && timeP2 != null -> return@Comparator 1 + else -> p1.name.compareTo(p2.name, ignoreCase = true) + } + }), + FILE_FIRST(Comparator { p1: PasswordItem, p2: PasswordItem -> (p2.type + p1.name).compareTo(p1.type + p2.name, ignoreCase = true) }); @@ -231,47 +244,5 @@ open class PasswordRepository protected constructor() { passwordList.sortWith(sortOrder.comparator) return passwordList } - - /** - * Sets the git user name - * - * @param username username - */ - @JvmStatic - fun setGitAuthorName(username: String) { - setStringConfig("user", null, "name", username) - } - - /** - * Sets the git user email - * - * @param email email - */ - @JvmStatic - fun setGitAuthorEmail(email: String) { - setStringConfig("user", null, "email", email) - } - - /** - * Sets a git config value - * - * @param section config section name - * @param subsection config subsection name - * @param name config name - * @param value the value to be set - */ - @JvmStatic - @Suppress("SameParameterValue") - private fun setStringConfig(section: String, subsection: String?, name: String, value: String) { - if (isInitialized) { - val config = repository!!.config - config.setString(section, subsection, name, value) - try { - config.save() - } catch (e: Exception) { - e.printStackTrace() - } - } - } } } diff --git a/app/src/main/res/layout/password_creation_activity.xml b/app/src/main/res/layout/password_creation_activity.xml index 9440d128..8e2f42b1 100644 --- a/app/src/main/res/layout/password_creation_activity.xml +++ b/app/src/main/res/layout/password_creation_activity.xml @@ -3,118 +3,125 @@ ~ SPDX-License-Identifier: GPL-3.0-only --> -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" + +<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - android:orientation="vertical" - android:padding="@dimen/activity_horizontal_margin" + android:fillViewport="false" tools:context="com.zeapo.pwdstore.crypto.PasswordCreationActivity"> - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/directory_input_layout" + <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_gravity="center_vertical" - android:layout_margin="8dp" - android:enabled="false" - android:hint="@string/directory_hint" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent"> + android:orientation="vertical" + android:padding="@dimen/activity_horizontal_margin"> - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/directory" + <com.google.android.material.textfield.TextInputLayout + android:id="@+id/directory_input_layout" android:layout_width="match_parent" android:layout_height="wrap_content" - android:imeOptions="actionNext" - android:inputType="textNoSuggestions" - android:nextFocusForward="@id/password" - tools:text="CATEGORY HERE" /> - </com.google.android.material.textfield.TextInputLayout> + android:layout_gravity="center_vertical" + android:layout_margin="8dp" + android:enabled="false" + android:hint="@string/directory_hint" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent"> - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/name_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_gravity="center_vertical" - android:layout_margin="8dp" - android:hint="@string/crypto_name_hint" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/directory_input_layout"> + <com.google.android.material.textfield.TextInputEditText + android:id="@+id/directory" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:imeOptions="actionNext" + android:inputType="textNoSuggestions" + android:nextFocusForward="@id/password" + tools:text="CATEGORY HERE" /> + </com.google.android.material.textfield.TextInputLayout> - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/filename" + <com.google.android.material.textfield.TextInputLayout + android:id="@+id/name_input_layout" android:layout_width="match_parent" android:layout_height="wrap_content" - android:imeOptions="actionNext" - android:inputType="textNoSuggestions" - android:nextFocusForward="@id/password" /> - </com.google.android.material.textfield.TextInputLayout> + android:layout_gravity="center_vertical" + android:layout_margin="8dp" + android:hint="@string/crypto_name_hint" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/directory_input_layout"> - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/password_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_margin="8dp" - android:hint="@string/crypto_pass_label" - app:endIconMode="password_toggle" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/name_input_layout"> + <com.google.android.material.textfield.TextInputEditText + android:id="@+id/filename" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:imeOptions="actionNext" + android:inputType="textNoSuggestions" + android:nextFocusForward="@id/password" /> + </com.google.android.material.textfield.TextInputLayout> - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/password" + <com.google.android.material.textfield.TextInputLayout + android:id="@+id/password_input_layout" android:layout_width="match_parent" android:layout_height="wrap_content" - android:imeOptions="actionDone" - android:inputType="textVisiblePassword" /> - </com.google.android.material.textfield.TextInputLayout> + android:layout_margin="8dp" + android:hint="@string/crypto_pass_label" + app:endIconMode="password_toggle" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/name_input_layout"> - <com.google.android.material.button.MaterialButton - android:id="@+id/generate_password" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_margin="8dp" - android:text="@string/pwd_generate_button" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@id/password_input_layout" /> + <com.google.android.material.textfield.TextInputEditText + android:id="@+id/password" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:imeOptions="actionDone" + android:inputType="textVisiblePassword" /> + </com.google.android.material.textfield.TextInputLayout> - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/extra_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_margin="8dp" - android:hint="@string/crypto_extra_label" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/generate_password"> + <com.google.android.material.button.MaterialButton + android:id="@+id/generate_password" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_margin="8dp" + android:text="@string/pwd_generate_button" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toBottomOf="@id/password_input_layout" /> - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/extra_content" + <com.google.android.material.textfield.TextInputLayout + android:id="@+id/extra_input_layout" android:layout_width="match_parent" android:layout_height="wrap_content" - android:inputType="textMultiLine|textVisiblePassword" /> + android:layout_margin="8dp" + android:hint="@string/crypto_extra_label" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/generate_password"> - </com.google.android.material.textfield.TextInputLayout> + <com.google.android.material.textfield.TextInputEditText + android:id="@+id/extra_content" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:inputType="textMultiLine|textVisiblePassword" /> - <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.textfield.TextInputLayout> - <com.google.android.material.switchmaterial.SwitchMaterial - android:id="@+id/encrypt_username" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_margin="8dp" - android:text="@string/crypto_encrypt_username_label" - android:visibility="gone" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/otp_import_button" - tools:visibility="visible" /> -</androidx.constraintlayout.widget.ConstraintLayout> + <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" + android:layout_height="wrap_content" + android:layout_margin="8dp" + android:text="@string/crypto_encrypt_username_label" + android:visibility="gone" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/otp_import_button" + tools:visibility="visible" /> + </androidx.constraintlayout.widget.ConstraintLayout> +</ScrollView> diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 6b5edf6c..2b320f31 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -8,11 +8,13 @@ <item>@string/pref_folder_first_sort_order</item> <item>@string/pref_file_first_sort_order</item> <item>@string/pref_type_independent_sort_order</item> + <item>@string/pref_recently_used_sort_order</item> </string-array> <string-array name="sort_order_values"> <item>FOLDER_FIRST</item> <item>FILE_FIRST</item> <item>INDEPENDENT</item> + <item>RECENTLY_USED</item> </string-array> <string-array name="capitalization_type_values"> <item>lowercase</item> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a7a9c141..1b32e29c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -140,6 +140,7 @@ <string name="pref_folder_first_sort_order">Folders first</string> <string name="pref_file_first_sort_order">Files first</string> <string name="pref_type_independent_sort_order">Type independent</string> + <string name="pref_recently_used_sort_order">Recently used</string> <string name="pref_autofill_title">Autofill</string> <string name="pref_autofill_enable_title">Enable Autofill</string> <string name="pref_autofill_enable_msg">Tap OK to go to Accessibility settings. There, tap Password Store under Services then tap the switch in the top right to turn it on or off.</string> diff --git a/app/src/main/res/xml/oreo_autofill_service.xml b/app/src/main/res/xml/oreo_autofill_service.xml index 8b76c803..7e761e25 100644 --- a/app/src/main/res/xml/oreo_autofill_service.xml +++ b/app/src/main/res/xml/oreo_autofill_service.xml @@ -14,6 +14,7 @@ <compatibility-package android:name="com.microsoft.emmx" /> <compatibility-package android:name="com.opera.mini.native" /> <compatibility-package android:name="com.opera.mini.native.beta" /> + <compatibility-package android:name="org.bromite.bromite" /> <compatibility-package android:name="org.mozilla.fennec_fdroid" android:maxLongVersionCode="679999" /> @@ -23,4 +24,5 @@ <compatibility-package android:name="org.mozilla.firefox_beta" android:maxLongVersionCode="679999" /> + <compatibility-package android:name="org.ungoogled.chromium.stable" /> </autofill-service> |