summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorFabian Henneke <FabianHenneke@users.noreply.github.com>2020-05-28 06:27:30 +0200
committerGitHub <noreply@github.com>2020-05-28 09:57:30 +0530
commit97911c587754a7c11e594004b77c4ac53af8a468 (patch)
tree680453c63fc3873139a3937268f73ec6985cf03a /app
parentfea82fed47d87138a9a24eb0358fc9a917474dc7 (diff)
Use SSHJ for SSH public key authentication (#801)
Diffstat (limited to 'app')
-rw-r--r--app/build.gradle2
-rw-r--r--app/lint.xml9
-rw-r--r--app/proguard-rules.pro3
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/Application.kt22
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt10
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/BreakOutOfDetached.kt4
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/CloneOperation.kt31
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.java145
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.kt148
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/GitOperation.kt160
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/PullOperation.kt6
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/PushOperation.kt6
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/ResetToRemoteOperation.kt6
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/SyncOperation.kt6
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/config/SshConfigSessionFactory.kt58
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/config/SshjSessionFactory.kt148
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenFragment.kt5
17 files changed, 419 insertions, 350 deletions
diff --git a/app/build.gradle b/app/build.gradle
index ad0355ac..2e0671a7 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 00000000..04e81e69
--- /dev/null
+++ b/app/lint.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?><!--
+ ~ Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ ~ SPDX-License-Identifier: GPL-3.0-only
+ -->
+<lint>
+ <issue id="InvalidPackage">
+ <ignore regexp="X509LDAPCertStoreSpi" />
+ </issue>
+</lint> \ No newline at end of file
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 83db6afc..474b45d0 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 d162e461..ec115639 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 fb7d7e82..c75f7ad3 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 94ee0845..31c376eb 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 32421d38..705566be 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 33e786a3..00000000
--- 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<GitCommand, Integer, String> {
- private WeakReference<Activity> 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 00000000..631bbf88
--- /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<GitCommand<*>, Int, GitAsyncTask.Result>() {
+
+ private val activityWeakReference: WeakReference<Activity> = 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 712d1bc5..bfcf2f11 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<TextInputEditText>(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<MaterialCheckBox>(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<TextInputEditText>(R.id.git_auth_passphrase)
+ val rememberPassphrase = dialogView.findViewById<MaterialCheckBox>(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<TextInputEditText>(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<TextInputEditText>(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 ec511a5b..2d5f6bbe 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 b2e87322..52e6e537 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 41427c09..c652889d 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 5188baff..1f241c64 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 9be49345..00000000
--- 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 00000000..5287989a
--- /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<String?>, isRetry: Boolean) -> Unit) : PasswordFinder {
+
+ private var isRetry = false
+ private var shouldRetry = true
+
+ override fun reqPassword(resource: Resource<*>?): CharArray {
+ val password = runBlocking(Dispatchers.Main) {
+ suspendCoroutine<String?> { 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 a7ed82b8..9b9d58f5 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)