mirror of
https://github.com/android-password-store/Android-Password-Store.git
synced 2025-09-05 04:44:04 +02:00
Use SSHJ for SSH public key authentication (#801)
This commit is contained in:
parent
fea82fed47
commit
97911c5877
|
@ -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
9
app/lint.xml
Normal 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>
|
3
app/proguard-rules.pro
vendored
3
app/proguard-rules.pro
vendored
|
@ -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 { *; }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
148
app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.kt
Normal file
148
app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Reference in a new issue