Use SSHJ for SSH public key authentication (#801)

This commit is contained in:
Fabian Henneke 2020-05-28 06:27:30 +02:00 committed by GitHub
parent fea82fed47
commit 97911c5877
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 421 additions and 350 deletions

View file

@ -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

9
app/lint.xml Normal file
View file

@ -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>

View file

@ -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 { *; }

View file

@ -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

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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
}
}
}
}
}

View file

@ -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()
}
}
}
}
}

View file

@ -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") }
}
}
}

View file

@ -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()

View file

@ -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()
}

View file

@ -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()
}

View file

@ -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()
}

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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)

View file

@ -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',