diff --git a/app/build.gradle b/app/build.gradle index ad0355acf..2e0671a7a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -101,6 +101,8 @@ dependencies { exclude group: 'org.apache.httpcomponents', module: 'httpclient' } implementation deps.third_party.jsch + implementation deps.third_party.sshj + implementation deps.third_party.bouncycastle implementation deps.third_party.openpgp_ktx implementation deps.third_party.ssh_auth implementation deps.third_party.timber diff --git a/app/lint.xml b/app/lint.xml new file mode 100644 index 000000000..04e81e695 --- /dev/null +++ b/app/lint.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 83db6afc5..474b45d00 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -24,3 +24,6 @@ -dontobfuscate -keep class com.jcraft.jsch.** -keep class org.eclipse.jgit.internal.JGitText { *; } +-keep class org.bouncycastle.jcajce.provider.** { *; } +-keep class org.bouncycastle.jce.provider.** { *; } +-keep class !org.bouncycastle.jce.provider.X509LDAPCertStoreSpi { *; } diff --git a/app/src/main/java/com/zeapo/pwdstore/Application.kt b/app/src/main/java/com/zeapo/pwdstore/Application.kt index d162e461d..ec1156398 100644 --- a/app/src/main/java/com/zeapo/pwdstore/Application.kt +++ b/app/src/main/java/com/zeapo/pwdstore/Application.kt @@ -14,6 +14,8 @@ import androidx.preference.PreferenceManager import com.github.ajalt.timberkt.Timber.DebugTree import com.github.ajalt.timberkt.Timber.plant import com.haroldadmin.whatthestack.WhatTheStack +import org.bouncycastle.jce.provider.BouncyCastleProvider +import java.security.Security @Suppress("Unused") class Application : android.app.Application(), SharedPreferences.OnSharedPreferenceChangeListener { @@ -29,6 +31,7 @@ class Application : android.app.Application(), SharedPreferences.OnSharedPrefere } prefs?.registerOnSharedPreferenceChangeListener(this) setNightMode() + setUpBouncyCastle() } override fun onTerminate() { @@ -42,6 +45,25 @@ class Application : android.app.Application(), SharedPreferences.OnSharedPrefere } } + 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))) { "light" -> MODE_NIGHT_NO 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 fb7d7e823..c75f7ad39 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt @@ -146,10 +146,11 @@ abstract class BaseGitActivity : AppCompatActivity() { } if (PasswordRepository.isInitialized) PasswordRepository.addRemote("origin", newUrl, true) - // HTTPS authentication sends the password to the server, so we must wipe the password when - // the server is changed. - if (previousUrl.isNotEmpty() && newUrl != previousUrl && protocol == Protocol.Https) + // When the server changes, remote password and host key file should be deleted. + if (previousUrl.isNotEmpty() && newUrl != previousUrl) { encryptedSettings.edit { remove("https_password") } + File("$filesDir/.host_key").delete() + } url = newUrl return GitUpdateUrlResult.Ok } @@ -201,8 +202,7 @@ abstract class BaseGitActivity : AppCompatActivity() { return } } - op.executeAfterAuthentication(connectionMode, serverUser, - File("$filesDir/.ssh_key"), identity) + op.executeAfterAuthentication(connectionMode, serverUser, identity) } catch (e: Exception) { e.printStackTrace() MaterialAlertDialogBuilder(this).setMessage(e.message).show() 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 94ee0845c..31c376eb2 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/BreakOutOfDetached.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/BreakOutOfDetached.kt @@ -62,10 +62,10 @@ class BreakOutOfDetached(fileDir: File, callingActivity: Activity) : GitOperatio .execute(*this.commands.toTypedArray()) } - override fun onError(errorMessage: String) { + override fun onError(err: Exception) { MaterialAlertDialogBuilder(callingActivity) .setTitle(callingActivity.resources.getString(R.string.jgit_error_dialog_title)) - .setMessage("Error occurred when checking out another branch operation $errorMessage") + .setMessage("Error occurred when checking out another branch operation ${err.message}") .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> callingActivity.finish() }.show() 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 32421d381..705566be1 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/CloneOperation.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/CloneOperation.kt @@ -34,43 +34,18 @@ class CloneOperation(fileDir: File, callingActivity: Activity) : GitOperation(fi return this } - /** - * sets the authentication for user/pwd scheme - * - * @param username the username - * @param password the password - * @return the current object - */ - public override fun setAuthentication(username: String, password: String): CloneOperation { - super.setAuthentication(username, password) - return this - } - - /** - * sets the authentication for the ssh-key scheme - * - * @param sshKey the ssh-key file - * @param username the username - * @param passphrase the passphrase - * @return the current object - */ - public override fun setAuthentication(sshKey: File, username: String, passphrase: String): CloneOperation { - super.setAuthentication(sshKey, username, passphrase) - return this - } - override fun execute() { (this.command as? CloneCommand)?.setCredentialsProvider(this.provider) GitAsyncTask(callingActivity, false, this, Intent()).execute(this.command) } - override fun onError(errorMessage: String) { - super.onError(errorMessage) + override fun onError(err: Exception) { + super.onError(err) MaterialAlertDialogBuilder(callingActivity) .setTitle(callingActivity.resources.getString(R.string.jgit_error_dialog_title)) .setMessage("Error occurred during the clone operation, " + callingActivity.resources.getString(R.string.jgit_error_dialog_text) + - errorMessage + + err.message + "\nPlease check the FAQ for possible reasons why this error might occur.") .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> } .show() diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.java b/app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.java deleted file mode 100644 index 33e786a35..000000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.java +++ /dev/null @@ -1,145 +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; - -import android.app.Activity; -import android.app.ProgressDialog; -import android.content.Intent; -import android.os.AsyncTask; - -import com.zeapo.pwdstore.PasswordStore; -import com.zeapo.pwdstore.R; - -import org.eclipse.jgit.api.CommitCommand; -import org.eclipse.jgit.api.GitCommand; -import org.eclipse.jgit.api.PullCommand; -import org.eclipse.jgit.api.PullResult; -import org.eclipse.jgit.api.PushCommand; -import org.eclipse.jgit.api.RebaseResult; -import org.eclipse.jgit.api.StatusCommand; -import org.eclipse.jgit.transport.PushResult; -import org.eclipse.jgit.transport.RemoteRefUpdate; - -import java.lang.ref.WeakReference; - -public class GitAsyncTask extends AsyncTask { - private WeakReference activityWeakReference; - private boolean refreshListOnEnd; - private ProgressDialog dialog; - private GitOperation operation; - private Intent finishWithResultOnEnd; - - public GitAsyncTask( - Activity activity, - boolean refreshListOnEnd, - GitOperation operation, - Intent finishWithResultOnEnd) { - this.activityWeakReference = new WeakReference<>(activity); - this.refreshListOnEnd = refreshListOnEnd; - this.operation = operation; - this.finishWithResultOnEnd = finishWithResultOnEnd; - - dialog = new ProgressDialog(getActivity()); - } - - private Activity getActivity() { - return activityWeakReference.get(); - } - - protected void onPreExecute() { - this.dialog.setMessage( - getActivity().getResources().getString(R.string.running_dialog_text)); - this.dialog.setCancelable(false); - this.dialog.show(); - } - - @Override - protected String doInBackground(GitCommand... commands) { - Integer nbChanges = null; - final Activity activity = getActivity(); - for (GitCommand command : commands) { - try { - if (command instanceof StatusCommand) { - // in case we have changes, we want to keep track of it - org.eclipse.jgit.api.Status status = ((StatusCommand) command).call(); - nbChanges = status.getChanged().size() + status.getMissing().size(); - } else if (command instanceof CommitCommand) { - // the previous status will eventually be used to avoid a commit - if (nbChanges == null || nbChanges > 0) command.call(); - } else if (command instanceof PullCommand) { - final PullResult result = ((PullCommand) command).call(); - final RebaseResult rr = result.getRebaseResult(); - - if (rr.getStatus() == RebaseResult.Status.STOPPED) { - return activity.getString(R.string.git_pull_fail_error); - } - - } else if (command instanceof PushCommand) { - for (final PushResult result : ((PushCommand) command).call()) { - // Code imported (modified) from Gerrit PushOp, license Apache v2 - for (final RemoteRefUpdate rru : result.getRemoteUpdates()) { - switch (rru.getStatus()) { - case REJECTED_NONFASTFORWARD: - return activity.getString(R.string.git_push_nff_error); - case REJECTED_NODELETE: - case REJECTED_REMOTE_CHANGED: - case NON_EXISTING: - case NOT_ATTEMPTED: - return activity.getString(R.string.git_push_generic_error) - + rru.getStatus().name(); - case REJECTED_OTHER_REASON: - if ("non-fast-forward".equals(rru.getMessage())) { - return activity.getString(R.string.git_push_other_error); - } else { - return activity.getString(R.string.git_push_generic_error) - + rru.getMessage(); - } - default: - break; - } - } - } - } else { - command.call(); - } - - } catch (Exception e) { - e.printStackTrace(); - return e.getMessage() + "\nCaused by:\n" + e.getCause(); - } - } - return ""; - } - - protected void onPostExecute(String result) { - if (this.dialog != null) - try { - this.dialog.dismiss(); - } catch (Exception e) { - // ignore - } - - if (result == null) result = "Unexpected error"; - - if (!result.isEmpty()) { - this.operation.onError(result); - } else { - this.operation.onSuccess(); - - if (finishWithResultOnEnd != null) { - this.getActivity().setResult(Activity.RESULT_OK, finishWithResultOnEnd); - this.getActivity().finish(); - } - - if (refreshListOnEnd) { - try { - ((PasswordStore) this.getActivity()).resetPasswordList(); - } catch (ClassCastException e) { - // oups, mistake - } - } - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.kt new file mode 100644 index 000000000..631bbf889 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.kt @@ -0,0 +1,148 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.git + +import android.app.Activity +import android.app.ProgressDialog +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 net.schmizz.sshj.userauth.UserAuthException +import org.eclipse.jgit.api.CommitCommand +import org.eclipse.jgit.api.GitCommand +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.transport.RemoteRefUpdate +import java.io.IOException +import java.lang.ref.WeakReference + + +class GitAsyncTask( + activity: Activity, + private val refreshListOnEnd: Boolean, + private val operation: GitOperation, + private val finishWithResultOnEnd: Intent?) : AsyncTask, Int, GitAsyncTask.Result>() { + + private val activityWeakReference: WeakReference = WeakReference(activity) + private val activity: Activity? + get() = activityWeakReference.get() + private val context: Context = activity.applicationContext + private val dialog = ProgressDialog(activity) + + sealed class Result { + object Ok : Result() + data class Err(val err: Exception) : Result() + } + + override fun onPreExecute() { + dialog.run { + setMessage(activity!!.resources.getString(R.string.running_dialog_text)) + setCancelable(false) + show() + } + } + + override fun doInBackground(vararg commands: GitCommand<*>): Result? { + var nbChanges: Int? = null + for (command in commands) { + try { + when (command) { + is StatusCommand -> { + // in case we have changes, we want to keep track of it + val status = command.call() + nbChanges = status.changed.size + status.missing.size + } + is CommitCommand -> { + // the previous status will eventually be used to avoid a commit + if (nbChanges == null || nbChanges > 0) command.call() + } + is PullCommand -> { + val result = command.call() + val rr = result.rebaseResult + if (rr.status === RebaseResult.Status.STOPPED) { + return Result.Err(IOException(context.getString(R.string + .git_pull_fail_error))) + } + } + is PushCommand -> { + for (result in command.call()) { + // Code imported (modified) from Gerrit PushOp, license Apache v2 + for (rru in result.remoteUpdates) { + val error = when (rru.status) { + RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD -> + context.getString(R.string.git_push_nff_error) + RemoteRefUpdate.Status.REJECTED_NODELETE, + RemoteRefUpdate.Status.REJECTED_REMOTE_CHANGED, + RemoteRefUpdate.Status.NON_EXISTING, + RemoteRefUpdate.Status.NOT_ATTEMPTED -> + (activity!!.getString(R.string.git_push_generic_error) + rru.status.name) + RemoteRefUpdate.Status.REJECTED_OTHER_REASON -> { + if + ("non-fast-forward" == rru.message) { + context.getString(R.string.git_push_other_error) + } else { + (context.getString(R.string.git_push_generic_error) + + rru.message) + } + } + else -> null + + } + if (error != null) + Result.Err(IOException(error)) + } + } + } + else -> { + command.call() + } + } + } catch (e: Exception) { + return Result.Err(e) + } + } + return Result.Ok + } + + private fun rootCauseException(e: Exception): Exception { + var rootCause = e + // JGit's TransportException hides the more helpful SSHJ exceptions. + // Also, SSHJ's UserAuthException about exhausting available authentication methods hides + // more useful exceptions. + while ((rootCause is org.eclipse.jgit.errors.TransportException || + rootCause is org.eclipse.jgit.api.errors.TransportException || + (rootCause is UserAuthException && + rootCause.message == "Exhausted available authentication methods"))) { + rootCause = rootCause.cause as? Exception ?: break + } + return rootCause + } + + override fun onPostExecute(maybeResult: Result?) { + dialog.dismiss() + when (val result = maybeResult ?: Result.Err(IOException("Unexpected error"))) { + is Result.Err -> { + e(result.err) + operation.onError(rootCauseException(result.err)) + } + is Result.Ok -> { + operation.onSuccess() + if (finishWithResultOnEnd != null) { + activity?.setResult(Activity.RESULT_OK, finishWithResultOnEnd) + activity?.finish() + } + if (refreshListOnEnd) { + (activity as? PasswordStore)?.resetPasswordList() + } + } + } + } + +} 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 712d1bc52..bfcf2f11e 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitOperation.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/GitOperation.kt @@ -13,15 +13,14 @@ import androidx.preference.PreferenceManager import com.google.android.material.checkbox.MaterialCheckBox import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputEditText -import com.jcraft.jsch.JSch -import com.jcraft.jsch.JSchException -import com.jcraft.jsch.KeyPair import com.zeapo.pwdstore.R import com.zeapo.pwdstore.UserPreference import com.zeapo.pwdstore.git.config.ConnectionMode import com.zeapo.pwdstore.git.config.GitConfigSessionFactory +import com.zeapo.pwdstore.git.config.InteractivePasswordFinder import com.zeapo.pwdstore.git.config.SshApiSessionFactory -import com.zeapo.pwdstore.git.config.SshConfigSessionFactory +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.getEncryptedPrefs import com.zeapo.pwdstore.utils.requestInputFocusOnView @@ -30,6 +29,7 @@ import org.eclipse.jgit.lib.Repository import org.eclipse.jgit.transport.SshSessionFactory import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider import java.io.File +import kotlin.coroutines.resume /** * Creates a new git operation @@ -42,6 +42,8 @@ abstract class GitOperation(fileDir: File, internal val callingActivity: Activit protected val repository: Repository? = PasswordRepository.getRepository(fileDir) internal var provider: UsernamePasswordCredentialsProvider? = null internal var command: GitCommand<*>? = null + private val sshKeyFile = callingActivity.filesDir.resolve(".ssh_key") + private val hostKeyFile = callingActivity.filesDir.resolve(".host_key") /** * Sets the authentication using user/pwd scheme @@ -56,16 +58,8 @@ abstract class GitOperation(fileDir: File, internal val callingActivity: Activit return this } - /** - * Sets the authentication using ssh-key scheme - * - * @param sshKey the ssh-key file - * @param username the username - * @param passphrase the passphrase - * @return the current object - */ - internal open fun setAuthentication(sshKey: File, username: String, passphrase: String): GitOperation { - val sessionFactory = SshConfigSessionFactory(sshKey.absolutePath, username, passphrase) + private fun withPublicKeyAuthentication(username: String, passphraseFinder: InteractivePasswordFinder): GitOperation { + val sessionFactory = SshjSessionFactory(username, SshAuthData.PublicKeyFile(sshKeyFile, passphraseFinder), hostKeyFile) SshSessionFactory.setInstance(sessionFactory) this.provider = null return this @@ -93,38 +87,17 @@ abstract class GitOperation(fileDir: File, internal val callingActivity: Activit * * @param connectionMode the server-connection mode * @param username the username - * @param sshKey the ssh-key file to use in ssh-key connection mode * @param identity the api identity to use for auth in OpenKeychain connection mode */ fun executeAfterAuthentication( connectionMode: ConnectionMode, username: String, - sshKey: File?, identity: SshApiSessionFactory.ApiIdentity? - ) { - executeAfterAuthentication(connectionMode, username, sshKey, identity, false) - } - - /** - * Executes the GitCommand in an async task after creating the authentication - * - * @param connectionMode the server-connection mode - * @param username the username - * @param sshKey the ssh-key file to use in ssh-key connection mode - * @param identity the api identity to use for auth in OpenKeychain connection mode - * @param showError show the passphrase edit text in red - */ - private fun executeAfterAuthentication( - connectionMode: ConnectionMode, - username: String, - sshKey: File?, - identity: SshApiSessionFactory.ApiIdentity?, - showError: Boolean ) { val encryptedSettings = callingActivity.applicationContext.getEncryptedPrefs("git_operation") when (connectionMode) { ConnectionMode.SshKey -> { - if (sshKey == null || !sshKey.exists()) { + if (!sshKeyFile.exists()) { MaterialAlertDialogBuilder(callingActivity) .setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text)) .setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title)) @@ -156,65 +129,47 @@ abstract class GitOperation(fileDir: File, internal val callingActivity: Activit callingActivity.finish() }.show() } else { - val layoutInflater = LayoutInflater.from(callingActivity) - @SuppressLint("InflateParams") val dialogView = layoutInflater.inflate(R.layout.git_passphrase_layout, null) - val passphrase = dialogView.findViewById(R.id.git_auth_passphrase) - val sshKeyPassphrase = encryptedSettings.getString("ssh_key_local_passphrase", null) - if (showError) { - passphrase.error = callingActivity.resources.getString(R.string.git_operation_wrong_passphrase) - } - val jsch = JSch() - try { - val keyPair = KeyPair.load(jsch, callingActivity.filesDir.toString() + "/.ssh_key") + withPublicKeyAuthentication(username, InteractivePasswordFinder { cont, isRetry -> + val storedPassphrase = encryptedSettings.getString("ssh_key_local_passphrase", null) + if (isRetry) + encryptedSettings.edit { putString("ssh_key_local_passphrase", null) } + if (storedPassphrase.isNullOrEmpty()) { + val layoutInflater = LayoutInflater.from(callingActivity) - if (keyPair.isEncrypted) { - if (sshKeyPassphrase != null && sshKeyPassphrase.isNotEmpty()) { - if (keyPair.decrypt(sshKeyPassphrase)) { - // Authenticate using the ssh-key and then execute the command - setAuthentication(sshKey, username, sshKeyPassphrase).execute() - } else { - // call back the method - executeAfterAuthentication(connectionMode, username, sshKey, identity, true) - } - } else { - val dialog = MaterialAlertDialogBuilder(callingActivity) - .setTitle(callingActivity.resources.getString(R.string.passphrase_dialog_title)) - .setMessage(callingActivity.resources.getString(R.string.passphrase_dialog_text)) - .setView(dialogView) - .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> - if (keyPair.decrypt(passphrase.text.toString())) { - if (dialogView.findViewById(R.id.git_auth_remember_passphrase).isChecked) { - encryptedSettings.edit { putString("ssh_key_local_passphrase", passphrase.text.toString()) } - } - // Authenticate using the ssh-key and then execute the command - setAuthentication(sshKey, username, passphrase.text.toString()).execute() - } else { - encryptedSettings.edit { putString("ssh_key_local_passphrase", null) } - // call back the method - executeAfterAuthentication(connectionMode, username, sshKey, identity, true) + @SuppressLint("InflateParams") + val dialogView = layoutInflater.inflate(R.layout.git_passphrase_layout, null) + val editPassphrase = dialogView.findViewById(R.id.git_auth_passphrase) + val rememberPassphrase = dialogView.findViewById(R.id.git_auth_remember_passphrase) + if (isRetry) + editPassphrase.error = callingActivity.resources.getString(R.string.git_operation_wrong_passphrase) + MaterialAlertDialogBuilder(callingActivity).run { + setTitle(R.string.passphrase_dialog_title) + setMessage(R.string.passphrase_dialog_text) + setView(dialogView) + setPositiveButton(R.string.dialog_ok) { _, _ -> + val passphrase = editPassphrase.text.toString() + if (rememberPassphrase.isChecked) { + encryptedSettings.edit { + putString("ssh_key_local_passphrase", passphrase) } } - .setNegativeButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ -> - callingActivity.finish() - } - .setOnCancelListener { callingActivity.finish() } - .create() - dialog.requestInputFocusOnView(R.id.git_auth_passphrase) - dialog.show() + cont.resume(passphrase) + } + setNegativeButton(R.string.dialog_cancel) { _, _ -> + cont.resume(null) + } + setOnCancelListener { + cont.resume(null) + } + create() + }.run { + requestInputFocusOnView(R.id.git_auth_passphrase) + show() } } else { - setAuthentication(sshKey, username, "").execute() + cont.resume(storedPassphrase) } - } catch (e: JSchException) { - e.printStackTrace() - MaterialAlertDialogBuilder(callingActivity) - .setTitle(callingActivity.resources.getString(R.string.git_operation_unable_to_open_ssh_key_title)) - .setMessage(callingActivity.resources.getString(R.string.git_operation_unable_to_open_ssh_key_message)) - .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> - callingActivity.finish() - } - .show() - } + }).execute() } } ConnectionMode.OpenKeychain -> { @@ -256,18 +211,23 @@ abstract class GitOperation(fileDir: File, internal val callingActivity: Activit /** * Action to execute on error */ - open fun onError(errorMessage: String) { + open fun onError(err: Exception) { // Clear various auth related fields on failure - if (SshSessionFactory.getInstance() is SshApiSessionFactory) { - PreferenceManager.getDefaultSharedPreferences(callingActivity.applicationContext) - .edit { putString("ssh_openkeystore_keyid", null) } - callingActivity.applicationContext - .getEncryptedPrefs("git_operation") - .edit { remove("ssh_key_local_passphrase") } - } else if (SshSessionFactory.getInstance() is GitConfigSessionFactory) { - callingActivity.applicationContext - .getEncryptedPrefs("git_operation") - .edit { remove("https_password") } + when (SshSessionFactory.getInstance()) { + is SshApiSessionFactory -> { + PreferenceManager.getDefaultSharedPreferences(callingActivity.applicationContext) + .edit { putString("ssh_openkeystore_keyid", null) } + } + is SshjSessionFactory -> { + callingActivity.applicationContext + .getEncryptedPrefs("git_operation") + .edit { remove("ssh_key_local_passphrase") } + } + is GitConfigSessionFactory -> { + callingActivity.applicationContext + .getEncryptedPrefs("git_operation") + .edit { remove("https_password") } + } } } 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 ec511a5bc..2d5f6bbeb 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/PullOperation.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/PullOperation.kt @@ -38,13 +38,13 @@ class PullOperation(fileDir: File, callingActivity: Activity) : GitOperation(fil GitAsyncTask(callingActivity, false, this, Intent()).execute(this.command) } - override fun onError(errorMessage: String) { - super.onError(errorMessage) + override fun onError(err: Exception) { + super.onError(err) MaterialAlertDialogBuilder(callingActivity) .setTitle(callingActivity.resources.getString(R.string.jgit_error_dialog_title)) .setMessage("Error occurred during the pull operation, " + callingActivity.resources.getString(R.string.jgit_error_dialog_text) + - errorMessage + + err.message + "\nPlease check the FAQ for possible reasons why this error might occur.") .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> callingActivity.finish() } .show() 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 b2e873227..52e6e5379 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/PushOperation.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/PushOperation.kt @@ -38,12 +38,12 @@ class PushOperation(fileDir: File, callingActivity: Activity) : GitOperation(fil GitAsyncTask(callingActivity, false, this, Intent()).execute(this.command) } - override fun onError(errorMessage: String) { + override fun onError(err: Exception) { // TODO handle the "Nothing to push" case - super.onError(errorMessage) + super.onError(err) MaterialAlertDialogBuilder(callingActivity) .setTitle(callingActivity.resources.getString(R.string.jgit_error_dialog_title)) - .setMessage(callingActivity.getString(R.string.jgit_error_push_dialog_text) + errorMessage) + .setMessage(callingActivity.getString(R.string.jgit_error_push_dialog_text) + err.message) .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> callingActivity.finish() } .show() } 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 41427c093..c652889d3 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/ResetToRemoteOperation.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/ResetToRemoteOperation.kt @@ -45,14 +45,14 @@ class ResetToRemoteOperation(fileDir: File, callingActivity: Activity) : GitOper .execute(this.addCommand, this.fetchCommand, this.resetCommand) } - override fun onError(errorMessage: String) { - super.onError(errorMessage) + override fun onError(err: Exception) { + super.onError(err) MaterialAlertDialogBuilder(callingActivity) .setTitle(callingActivity.resources.getString(R.string.jgit_error_dialog_title)) .setMessage("Error occurred during the sync operation, " + "\nPlease check the FAQ for possible reasons why this error might occur." + callingActivity.resources.getString(R.string.jgit_error_dialog_text) + - errorMessage) + err) .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> } .show() } 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 5188baffa..1f241c64f 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/SyncOperation.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/SyncOperation.kt @@ -53,14 +53,14 @@ class SyncOperation(fileDir: File, callingActivity: Activity) : GitOperation(fil GitAsyncTask(callingActivity, false, this, Intent()).execute(this.addCommand, this.statusCommand, this.commitCommand, this.pullCommand, this.pushCommand) } - override fun onError(errorMessage: String) { - super.onError(errorMessage) + override fun onError(err: Exception) { + super.onError(err) MaterialAlertDialogBuilder(callingActivity) .setTitle(callingActivity.resources.getString(R.string.jgit_error_dialog_title)) .setMessage("Error occurred during the sync operation, " + "\nPlease check the FAQ for possible reasons why this error might occur." + callingActivity.resources.getString(R.string.jgit_error_dialog_text) + - errorMessage) + err) .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> callingActivity.finish() } .show() } diff --git a/app/src/main/java/com/zeapo/pwdstore/git/config/SshConfigSessionFactory.kt b/app/src/main/java/com/zeapo/pwdstore/git/config/SshConfigSessionFactory.kt deleted file mode 100644 index 9be49345a..000000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/config/SshConfigSessionFactory.kt +++ /dev/null @@ -1,58 +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 com.jcraft.jsch.JSch -import com.jcraft.jsch.JSchException -import com.jcraft.jsch.Session -import org.eclipse.jgit.errors.UnsupportedCredentialItem -import org.eclipse.jgit.transport.CredentialItem -import org.eclipse.jgit.transport.CredentialsProvider -import org.eclipse.jgit.transport.CredentialsProviderUserInfo -import org.eclipse.jgit.transport.OpenSshConfig -import org.eclipse.jgit.transport.URIish -import org.eclipse.jgit.util.FS - -class SshConfigSessionFactory(private val sshKey: String, private val username: String, private val passphrase: String) : GitConfigSessionFactory() { - - @Throws(JSchException::class) - override fun getJSch(hc: OpenSshConfig.Host, fs: FS): JSch { - val jsch = super.getJSch(hc, fs) - jsch.removeAllIdentity() - jsch.addIdentity(sshKey) - return jsch - } - - override fun configure(hc: OpenSshConfig.Host, session: Session) { - session.setConfig("StrictHostKeyChecking", "no") - session.setConfig("PreferredAuthentications", "publickey,password") - - val provider = object : CredentialsProvider() { - override fun isInteractive(): Boolean { - return false - } - - override fun supports(vararg items: CredentialItem): Boolean { - return true - } - - @Throws(UnsupportedCredentialItem::class) - override fun get(uri: URIish, vararg items: CredentialItem): Boolean { - for (item in items) { - if (item is CredentialItem.Username) { - item.value = username - continue - } - if (item is CredentialItem.StringType) { - item.value = passphrase - } - } - return true - } - } - val userInfo = CredentialsProviderUserInfo(session, provider) - session.userInfo = userInfo - } -} 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 new file mode 100644 index 000000000..5287989a6 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/config/SshjSessionFactory.kt @@ -0,0 +1,148 @@ +/* + * 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.util.Base64 +import com.github.ajalt.timberkt.d +import com.github.ajalt.timberkt.w +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import net.schmizz.sshj.SSHClient +import net.schmizz.sshj.common.Buffer.PlainBuffer +import net.schmizz.sshj.common.SSHRuntimeException +import net.schmizz.sshj.common.SecurityUtils +import net.schmizz.sshj.connection.channel.direct.Session +import net.schmizz.sshj.transport.verification.FingerprintVerifier +import net.schmizz.sshj.transport.verification.HostKeyVerifier +import net.schmizz.sshj.userauth.password.PasswordFinder +import net.schmizz.sshj.userauth.password.Resource +import org.eclipse.jgit.transport.CredentialsProvider +import org.eclipse.jgit.transport.RemoteSession +import org.eclipse.jgit.transport.SshSessionFactory +import org.eclipse.jgit.transport.URIish +import org.eclipse.jgit.util.FS +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.security.GeneralSecurityException +import java.util.concurrent.TimeUnit +import kotlin.coroutines.Continuation +import kotlin.coroutines.suspendCoroutine + +sealed class SshAuthData { + class Password(val passwordFinder: InteractivePasswordFinder) : SshAuthData() + class PublicKeyFile(val keyFile: File, val passphraseFinder: InteractivePasswordFinder) : SshAuthData() +} + +class InteractivePasswordFinder(val askForPassword: (cont: Continuation, isRetry: Boolean) -> Unit) : PasswordFinder { + + private var isRetry = false + private var shouldRetry = true + + override fun reqPassword(resource: Resource<*>?): CharArray { + val password = runBlocking(Dispatchers.Main) { + suspendCoroutine { cont -> + askForPassword(cont, isRetry) + } + } + isRetry = true + return if (password != null) { + password.toCharArray() + } else { + shouldRetry = false + CharArray(0) + } + } + + override fun shouldRetry(resource: Resource<*>?) = shouldRetry +} + +class SshjSessionFactory(private val username: String, private val authData: SshAuthData, private val hostKeyFile: File) : SshSessionFactory() { + + override fun getSession(uri: URIish, credentialsProvider: CredentialsProvider?, fs: FS?, tms: Int): RemoteSession { + return SshjSession(uri, username, authData, hostKeyFile).connect() + } +} + +private fun makeTofuHostKeyVerifier(hostKeyFile: File): HostKeyVerifier { + if (!hostKeyFile.exists()) { + return HostKeyVerifier { _, _, key -> + val digest = try { + SecurityUtils.getMessageDigest("SHA-256") + } catch (e: GeneralSecurityException) { + throw SSHRuntimeException(e) + } + digest.update(PlainBuffer().putPublicKey(key).compactData) + val digestData = digest.digest() + val hostKeyEntry = "SHA256:${Base64.encodeToString(digestData, Base64.NO_WRAP)}" + d { "Trusting host key on first use: $hostKeyEntry" } + hostKeyFile.writeText(hostKeyEntry) + true + } + } else { + val hostKeyEntry = hostKeyFile.readText() + d { "Pinned host key: $hostKeyEntry" } + return FingerprintVerifier.getInstance(hostKeyEntry) + } +} + +private class SshjSession(private val uri: URIish, private val username: String, private val authData: SshAuthData, private val hostKeyFile: File) : RemoteSession { + + private lateinit var ssh: SSHClient + private var currentCommand: Session? = null + + fun connect(): SshjSession { + ssh = SSHClient() + ssh.addHostKeyVerifier(makeTofuHostKeyVerifier(hostKeyFile)) + ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22) + if (!ssh.isConnected) + throw IOException() + when (authData) { + is SshAuthData.Password -> { + ssh.authPassword(username, authData.passwordFinder) + } + is SshAuthData.PublicKeyFile -> { + ssh.authPublickey(username, ssh.loadKeys(authData.keyFile.absolutePath, authData.passphraseFinder)) + } + } + return this + } + + override fun exec(commandName: String?, timeout: Int): Process { + if (currentCommand != null) { + w { "Killing old session" } + currentCommand?.close() + currentCommand = null + } + val session = ssh.startSession() + currentCommand = session + return SshjProcess(session.exec(commandName), timeout.toLong()) + } + + override fun disconnect() { + currentCommand?.close() + ssh.close() + } +} + +private class SshjProcess(private val command: Session.Command, private val timeout: Long) : Process() { + + override fun waitFor(): Int { + command.join(timeout, TimeUnit.SECONDS) + command.close() + return exitValue() + } + + override fun destroy() = command.close() + + override fun getOutputStream(): OutputStream = command.outputStream + + override fun getErrorStream(): InputStream = command.errorStream + + override fun exitValue(): Int = command.exitStatus + + override fun getInputStream(): InputStream = command.inputStream +} diff --git a/app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenFragment.kt b/app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenFragment.kt index a7ed82b8f..9b9d58f53 100644 --- a/app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenFragment.kt +++ b/app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenFragment.kt @@ -17,6 +17,7 @@ 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 @@ -74,6 +75,10 @@ class SshKeyGenFragment : Fragment(R.layout.fragment_ssh_keygen) { } 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) diff --git a/dependencies.gradle b/dependencies.gradle index 72ab67573..545a74265 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -50,6 +50,8 @@ ext.deps = [ fastscroll: 'me.zhanghai.android.fastscroll:library:1.1.3', jsch: 'com.jcraft:jsch:0.1.55', jgit: 'org.eclipse.jgit:org.eclipse.jgit:3.7.1.201504261725-r', + sshj: 'com.hierynomus:sshj:0.29.0', + bouncycastle: 'org.bouncycastle:bcprov-jdk15on:1.65', leakcanary: 'com.squareup.leakcanary:leakcanary-android:2.2', openpgp_ktx: 'com.github.android-password-store:openpgp-ktx:2.0.0', ssh_auth: 'org.sufficientlysecure:sshauthentication-api:1.0',