From 152d86ec3a951bd6cb797128094d933d2c3a5697 Mon Sep 17 00:00:00 2001 From: Fabian Henneke Date: Tue, 18 Aug 2020 22:02:34 +0200 Subject: Add SSHJ backend for OpenKeychain authentication (#995) * Update sshj to 0.30.0 and improve algorithm order Updates sshj to 0.30.0, which brings support for rsa-sha2-* key types and bugfixes related to RSA certificates and Android Keystore backed keys. Along the way, this improves the algorithm preferences to be consistent with the Mozilla Intermediate SSH configuration (as far as possible, given that most certificate types and some encryption algorithms are not yet supported). We also add "ext-info-c" to the kex algorithm proposal to work around certain kinds of "user agent sniffing" that limits the support of rsa-sha2-* key types. * Add SSHJ backend for OpenKeychain authentication * Address review comments Co-authored-by: Harsh Shandilya --- .../main/java/com/zeapo/pwdstore/Application.kt | 2 +- .../java/com/zeapo/pwdstore/git/BaseGitActivity.kt | 77 +---- .../com/zeapo/pwdstore/git/GitCommandExecutor.kt | 2 +- .../pwdstore/git/config/SshApiSessionFactory.java | 383 --------------------- .../com/zeapo/pwdstore/git/config/SshjConfig.kt | 275 --------------- .../pwdstore/git/config/SshjSessionFactory.kt | 180 ---------- .../pwdstore/git/operation/CredentialFinder.kt | 2 +- .../zeapo/pwdstore/git/operation/GitOperation.kt | 30 +- .../pwdstore/git/sshj/OpenKeychainKeyProvider.kt | 212 ++++++++++++ .../sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt | 93 +++++ .../java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt | 277 +++++++++++++++ .../zeapo/pwdstore/git/sshj/SshjSessionFactory.kt | 189 ++++++++++ 12 files changed, 787 insertions(+), 935 deletions(-) delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/config/SshApiSessionFactory.java delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/config/SshjConfig.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/config/SshjSessionFactory.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainKeyProvider.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjSessionFactory.kt diff --git a/app/src/main/java/com/zeapo/pwdstore/Application.kt b/app/src/main/java/com/zeapo/pwdstore/Application.kt index 544eb047..3108dee3 100644 --- a/app/src/main/java/com/zeapo/pwdstore/Application.kt +++ b/app/src/main/java/com/zeapo/pwdstore/Application.kt @@ -12,7 +12,7 @@ import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_NO import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES import com.github.ajalt.timberkt.Timber.DebugTree import com.github.ajalt.timberkt.Timber.plant -import com.zeapo.pwdstore.git.config.setUpBouncyCastleForSshj +import com.zeapo.pwdstore.git.sshj.setUpBouncyCastleForSshj import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.sharedPrefs import com.zeapo.pwdstore.utils.getString diff --git a/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt index fb0831d6..21795404 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt @@ -4,17 +4,12 @@ */ package com.zeapo.pwdstore.git -import android.content.Intent import android.view.MenuItem -import androidx.annotation.CallSuper import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.lifecycleScope import com.github.ajalt.timberkt.Timber.tag import com.github.ajalt.timberkt.e import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zeapo.pwdstore.git.config.ConnectionMode import com.zeapo.pwdstore.git.config.GitSettings -import com.zeapo.pwdstore.git.config.SshApiSessionFactory import com.zeapo.pwdstore.git.operation.BreakOutOfDetached import com.zeapo.pwdstore.git.operation.CloneOperation import com.zeapo.pwdstore.git.operation.GitOperation @@ -23,7 +18,6 @@ import com.zeapo.pwdstore.git.operation.PushOperation import com.zeapo.pwdstore.git.operation.ResetToRemoteOperation import com.zeapo.pwdstore.git.operation.SyncOperation import com.zeapo.pwdstore.utils.PasswordRepository -import kotlinx.coroutines.launch /** * Abstract AppCompatActivity that holds some information that is commonly shared across git-related @@ -31,9 +25,6 @@ import kotlinx.coroutines.launch */ abstract class BaseGitActivity : AppCompatActivity() { - private var identityBuilder: SshApiSessionFactory.IdentityBuilder? = null - private var identity: SshApiSessionFactory.ApiIdentity? = null - override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { android.R.id.home -> { @@ -44,23 +35,8 @@ abstract class BaseGitActivity : AppCompatActivity() { } } - @CallSuper - override fun onDestroy() { - // Do not leak the service connection - if (identityBuilder != null) { - identityBuilder!!.close() - identityBuilder = null - } - super.onDestroy() - } - /** - * Attempt to launch the requested Git operation. Depending on the configured auth, it may not - * be possible to launch the operation immediately. In that case, this function may launch an - * intermediate activity instead, which will gather necessary information and post it back via - * onActivityResult, which will then re-call this function. This may happen multiple times, - * until either an error is encountered or the operation is successfully launched. - * + * Attempt to launch the requested Git operation. * @param operation The type of git operation to launch */ suspend fun launchGitOperation(operation: Int) { @@ -70,21 +46,6 @@ abstract class BaseGitActivity : AppCompatActivity() { return } try { - // Before launching the operation with OpenKeychain auth, we need to issue several requests - // to the OpenKeychain API. IdentityBuild will take care of launching the relevant intents, - // we just need to keep calling it until it returns a completed ApiIdentity. - if (GitSettings.connectionMode == ConnectionMode.OpenKeychain && identity == null) { - // Lazy initialization of the IdentityBuilder - if (identityBuilder == null) { - identityBuilder = SshApiSessionFactory.IdentityBuilder(this) - } - // Try to get an ApiIdentity and bail if one is not ready yet. The builder will ensure - // that onActivityResult is called with operation again, which will re-invoke us here - identity = identityBuilder!!.tryBuild(operation) - if (identity == null) - return - } - val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory()) val op = when (operation) { REQUEST_CLONE, GitOperation.GET_SSH_KEY_FROM_CLONE -> CloneOperation(localDir, GitSettings.url!!, this) @@ -93,7 +54,6 @@ abstract class BaseGitActivity : AppCompatActivity() { REQUEST_SYNC -> SyncOperation(localDir, this) BREAK_OUT_OF_DETACHED -> BreakOutOfDetached(localDir, this) REQUEST_RESET -> ResetToRemoteOperation(localDir, this) - SshApiSessionFactory.POST_SIGNATURE -> return else -> { tag(TAG).e { "Operation not recognized : $operation" } setResult(RESULT_CANCELED) @@ -101,46 +61,13 @@ abstract class BaseGitActivity : AppCompatActivity() { return } } - op.executeAfterAuthentication(GitSettings.connectionMode, identity) + op.executeAfterAuthentication(GitSettings.connectionMode) } catch (e: Exception) { e.printStackTrace() MaterialAlertDialogBuilder(this).setMessage(e.message).show() } } - public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - // In addition to the pre-operation-launch series of intents for OpenKeychain auth - // that will pass through here and back to launchGitOperation, there is one - // synchronous operation that happens /after/ the operation has been launched in the - // background thread - the actual signing of the SSH challenge. We pass through the - // completed signature to the ApiIdentity, which will be blocked in the other thread - // waiting for it. - if (requestCode == SshApiSessionFactory.POST_SIGNATURE && identity != null) { - identity!!.postSignature(data) - - // If the signature failed (usually because it was cancelled), reset state - if (data == null) { - identity = null - identityBuilder = null - } - return - } - - if (resultCode == RESULT_CANCELED) { - setResult(RESULT_CANCELED) - finish() - } else if (resultCode == RESULT_OK) { - // If an operation has been re-queued via this mechanism, let the - // IdentityBuilder attempt to extract some updated state from the intent before - // trying to re-launch the operation. - if (identityBuilder != null) { - identityBuilder!!.consume(data) - } - lifecycleScope.launch { launchGitOperation(requestCode) } - } - super.onActivityResult(requestCode, resultCode, data) - } - companion object { const val REQUEST_ARG_OP = "OPERATION" diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt index f06fc891..e98f66ad 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt @@ -14,7 +14,7 @@ import com.google.android.material.snackbar.Snackbar import com.zeapo.pwdstore.R import com.zeapo.pwdstore.git.GitException.PullException import com.zeapo.pwdstore.git.GitException.PushException -import com.zeapo.pwdstore.git.config.SshjSessionFactory +import com.zeapo.pwdstore.git.sshj.SshjSessionFactory import com.zeapo.pwdstore.git.operation.GitOperation import com.zeapo.pwdstore.utils.Result import com.zeapo.pwdstore.utils.snackbar diff --git a/app/src/main/java/com/zeapo/pwdstore/git/config/SshApiSessionFactory.java b/app/src/main/java/com/zeapo/pwdstore/git/config/SshApiSessionFactory.java deleted file mode 100644 index 03760741..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/config/SshApiSessionFactory.java +++ /dev/null @@ -1,383 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.config; - -import android.app.PendingIntent; -import android.content.Intent; -import android.content.IntentSender; -import android.content.SharedPreferences; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AppCompatActivity; -import androidx.preference.PreferenceManager; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.jcraft.jsch.Identity; -import com.jcraft.jsch.JSch; -import com.jcraft.jsch.JSchException; -import com.jcraft.jsch.Session; -import com.zeapo.pwdstore.R; -import com.zeapo.pwdstore.git.BaseGitActivity; -import com.zeapo.pwdstore.utils.PreferenceKeys; - -import org.eclipse.jgit.transport.JschConfigSessionFactory; -import org.eclipse.jgit.transport.OpenSshConfig; -import org.eclipse.jgit.util.Base64; -import org.eclipse.jgit.util.FS; -import org.openintents.ssh.authentication.ISshAuthenticationService; -import org.openintents.ssh.authentication.SshAuthenticationApi; -import org.openintents.ssh.authentication.SshAuthenticationApiError; -import org.openintents.ssh.authentication.SshAuthenticationConnection; -import org.openintents.ssh.authentication.request.KeySelectionRequest; -import org.openintents.ssh.authentication.request.Request; -import org.openintents.ssh.authentication.request.SigningRequest; -import org.openintents.ssh.authentication.request.SshPublicKeyRequest; -import org.openintents.ssh.authentication.util.SshAuthenticationApiUtils; - -import java.util.List; -import java.util.concurrent.CountDownLatch; - -public class SshApiSessionFactory extends JschConfigSessionFactory { - /** - * Intent request code indicating a completed signature that should be posted to an outstanding - * ApiIdentity - */ - public static final int POST_SIGNATURE = 301; - - private final Identity identity; - - public SshApiSessionFactory(Identity identity) { - this.identity = identity; - } - - @NonNull - @Override - protected JSch getJSch(@NonNull final OpenSshConfig.Host hc, @NonNull FS fs) - throws JSchException { - JSch jsch = super.getJSch(hc, fs); - jsch.removeAllIdentity(); - jsch.addIdentity(identity, null); - return jsch; - } - - @Override - protected void configure(@NonNull OpenSshConfig.Host hc, Session session) { - session.setConfig("StrictHostKeyChecking", "no"); - session.setConfig("PreferredAuthentications", "publickey"); - } - - /** - * Helper to build up an ApiIdentity via the invocation of several pending intents that - * communicate with OpenKeychain. The user of this class must handle onActivityResult and keep - * feeding the resulting intents into the IdentityBuilder until it can successfully complete the - * build. - */ - public static class IdentityBuilder { - private final SshAuthenticationConnection connection; - private final BaseGitActivity callingActivity; - private final SharedPreferences settings; - private SshAuthenticationApi api; - private String keyId, description, alg; - private byte[] publicKey; - - /** - * Construct a new IdentityBuilder - * - * @param callingActivity Activity that will be used to launch pending intents and that will - * receive and handle the results. - */ - public IdentityBuilder(BaseGitActivity callingActivity) { - this.callingActivity = callingActivity; - - List providers = - SshAuthenticationApiUtils.getAuthenticationProviderPackageNames( - callingActivity); - if (providers.isEmpty()) - throw new RuntimeException(callingActivity.getString(R.string.no_ssh_api_provider)); - - // TODO: Handle multiple available providers? Are there actually any in practice beyond - // OpenKeychain? - connection = new SshAuthenticationConnection(callingActivity, providers.get(0)); - - settings = - PreferenceManager.getDefaultSharedPreferences( - callingActivity.getApplicationContext()); - keyId = settings.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null); - } - - /** - * Free any resources associated with this IdentityBuilder - */ - public void close() { - if (connection != null && connection.isConnected()) connection.disconnect(); - } - - /** - * Helper to invoke an OpenKeyshain SSH API method and correctly interpret the result. - * - * @param request The request intent to launch - * @param requestCode The request code to use if a pending intent needs to be sent - * @return The resulting intent if the request completed immediately, or null if we had to - * launch a pending intent to interact with the user - */ - private Intent executeApi(Request request, int requestCode) { - Intent result = api.executeApi(request.toIntent()); - - switch (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, -1)) { - case SshAuthenticationApi.RESULT_CODE_ERROR: - SshAuthenticationApiError error = - result.getParcelableExtra(SshAuthenticationApi.EXTRA_ERROR); - // On an OpenKeychain SSH API error, clear out the stored keyid - settings.edit().putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null).apply(); - - switch (error.getError()) { - // If the problem was just a bad keyid, reset to allow them to choose a - // different one - case (SshAuthenticationApiError.NO_SUCH_KEY): - case (SshAuthenticationApiError.NO_AUTH_KEY): - keyId = null; - publicKey = null; - description = null; - alg = null; - return executeApi(new KeySelectionRequest(), requestCode); - - // Other errors are fatal - default: - throw new RuntimeException(error.getMessage()); - } - case SshAuthenticationApi.RESULT_CODE_SUCCESS: - break; - case SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED: - PendingIntent pendingIntent = - result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT); - try { - callingActivity.startIntentSenderForResult( - pendingIntent.getIntentSender(), requestCode, null, 0, 0, 0); - return null; - } catch (IntentSender.SendIntentException e) { - e.printStackTrace(); - throw new RuntimeException( - callingActivity.getString(R.string.ssh_api_pending_intent_failed)); - } - default: - throw new RuntimeException( - callingActivity.getString(R.string.ssh_api_unknown_error)); - } - - return result; - } - - /** - * Parse a given intent to see if it is the result of an OpenKeychain pending intent. If so, - * extract any updated state from it. - * - * @param intent The intent to inspect - */ - public void consume(Intent intent) { - if (intent == null) return; - - if (intent.hasExtra(SshAuthenticationApi.EXTRA_KEY_ID)) { - keyId = intent.getStringExtra(SshAuthenticationApi.EXTRA_KEY_ID); - description = intent.getStringExtra(SshAuthenticationApi.EXTRA_KEY_DESCRIPTION); - settings.edit().putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, keyId).apply(); - } - - if (intent.hasExtra(SshAuthenticationApi.EXTRA_SSH_PUBLIC_KEY)) { - String keyStr = intent.getStringExtra(SshAuthenticationApi.EXTRA_SSH_PUBLIC_KEY); - String[] keyParts = keyStr.split(" "); - alg = keyParts[0]; - publicKey = Base64.decode(keyParts[1]); - } - } - - /** - * Try to build an ApiIdentity that will perform SSH authentication via OpenKeychain. - * - * @param requestCode The request code to use if a pending intent needs to be sent - * @return The built identity, or null of user interaction is still required (in which case - * a pending intent will have already been launched) - */ - public ApiIdentity tryBuild(int requestCode) { - // First gate, need to initiate a connection to the service and wait for it to connect. - if (api == null) { - connection.connect( - new SshAuthenticationConnection.OnBound() { - @Override - public void onBound(ISshAuthenticationService sshAgent) { - api = new SshAuthenticationApi(callingActivity, sshAgent); - // We can immediately try the next phase without needing to post - // back - // though onActivityResult - callingActivity.onActivityResult( - requestCode, AppCompatActivity.RESULT_OK, null); - } - - @Override - public void onError() { - new MaterialAlertDialogBuilder(callingActivity) - .setMessage( - callingActivity.getString( - R.string.openkeychain_ssh_api_connect_fail)) - .show(); - } - }); - - return null; - } - - // Second gate, need the user to select which key they want to use - if (keyId == null) { - consume(executeApi(new KeySelectionRequest(), requestCode)); - // If we did not immediately get the result, bail for now and wait to be re-entered - if (keyId == null) return null; - } - - // Third gate, need to get the public key for the selected key. This one often does not - // need use interaction. - if (publicKey == null) { - consume(executeApi(new SshPublicKeyRequest(keyId), requestCode)); - // If we did not immediately get the result, bail for now and wait to be re-entered - if (publicKey == null) return null; - } - - // Have everything we need for now, build the identify - return new ApiIdentity(keyId, description, publicKey, alg, callingActivity, api); - } - } - - /** - * A Jsch identity that delegates key operations via the OpenKeychain SSH API - */ - public static class ApiIdentity implements Identity { - private final String keyId; - private final String description; - private final String alg; - private final byte[] publicKey; - private final AppCompatActivity callingActivity; - private final SshAuthenticationApi api; - private CountDownLatch latch; - private byte[] signature; - - ApiIdentity( - String keyId, - String description, - byte[] publicKey, - String alg, - AppCompatActivity callingActivity, - SshAuthenticationApi api) { - this.keyId = keyId; - this.description = description; - this.publicKey = publicKey; - this.alg = alg; - this.callingActivity = callingActivity; - this.api = api; - } - - @Override - public boolean setPassphrase(byte[] passphrase) { - // We are not encrypted with a passphrase - return true; - } - - @Override - public byte[] getPublicKeyBlob() { - return publicKey; - } - - /** - * Helper to handle the result of an OpenKeyshain SSH API signing request - * - * @param result The result intent to handle - * @return The signed challenge, or null if it was not immediately available, in which case - * the latch has been initialized and the pending intent started - */ - private byte[] handleSignResult(Intent result) { - switch (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, -1)) { - case SshAuthenticationApi.RESULT_CODE_ERROR: - SshAuthenticationApiError error = - result.getParcelableExtra(SshAuthenticationApi.EXTRA_ERROR); - throw new RuntimeException(error.getMessage()); - case SshAuthenticationApi.RESULT_CODE_SUCCESS: - return result.getByteArrayExtra(SshAuthenticationApi.EXTRA_SIGNATURE); - case SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED: - PendingIntent pendingIntent = - result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT); - try { - latch = new CountDownLatch(1); - callingActivity.startIntentSenderForResult( - pendingIntent.getIntentSender(), POST_SIGNATURE, null, 0, 0, 0); - return null; - - } catch (Exception e) { - e.printStackTrace(); - throw new RuntimeException( - callingActivity.getString(R.string.ssh_api_pending_intent_failed)); - } - default: - if (result.hasExtra(SshAuthenticationApi.EXTRA_CHALLENGE)) - return handleSignResult(api.executeApi(result)); - throw new RuntimeException( - callingActivity.getString(R.string.ssh_api_unknown_error)); - } - } - - @Override - public byte[] getSignature(byte[] data) { - Intent request = new SigningRequest(data, keyId, SshAuthenticationApi.SHA1).toIntent(); - signature = handleSignResult(api.executeApi(request)); - - // If we did not immediately get a signature (probable), we will block on a latch until - // the main activity gets the intent result and posts to us. - if (signature == null) { - try { - latch.await(); - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - - return signature; - } - - /** - * Post a signature response back to an in-progress operation using this ApiIdentity. - * - * @param data The signature data (hopefully) - */ - public void postSignature(Intent data) { - try { - if (data != null) { - signature = handleSignResult(data); - } - } finally { - if (latch != null) latch.countDown(); - } - } - - @Override - public boolean decrypt() { - return true; - } - - @Override - public String getAlgName() { - return alg; - } - - @Override - public String getName() { - return description; - } - - @Override - public boolean isEncrypted() { - return false; - } - - @Override - public void clear() { - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/config/SshjConfig.kt b/app/src/main/java/com/zeapo/pwdstore/git/config/SshjConfig.kt deleted file mode 100644 index 6c409329..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/config/SshjConfig.kt +++ /dev/null @@ -1,275 +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.github.ajalt.timberkt.Timber -import com.github.ajalt.timberkt.d -import com.hierynomus.sshj.key.KeyAlgorithms -import com.hierynomus.sshj.transport.cipher.BlockCiphers -import com.hierynomus.sshj.transport.kex.ExtInfoClientFactory -import com.hierynomus.sshj.transport.mac.Macs -import com.hierynomus.sshj.userauth.keyprovider.OpenSSHKeyV1KeyFile -import java.security.Security -import net.schmizz.keepalive.KeepAliveProvider -import net.schmizz.sshj.ConfigImpl -import net.schmizz.sshj.common.LoggerFactory -import net.schmizz.sshj.transport.compression.NoneCompression -import net.schmizz.sshj.transport.kex.Curve25519SHA256 -import net.schmizz.sshj.transport.kex.Curve25519SHA256.FactoryLibSsh -import net.schmizz.sshj.transport.kex.DHGexSHA256 -import net.schmizz.sshj.transport.kex.ECDHNistP -import net.schmizz.sshj.transport.random.JCERandom -import net.schmizz.sshj.transport.random.SingletonRandomFactory -import net.schmizz.sshj.userauth.keyprovider.OpenSSHKeyFile -import net.schmizz.sshj.userauth.keyprovider.PKCS5KeyFile -import net.schmizz.sshj.userauth.keyprovider.PKCS8KeyFile -import net.schmizz.sshj.userauth.keyprovider.PuTTYKeyFile -import org.bouncycastle.jce.provider.BouncyCastleProvider -import org.slf4j.Logger -import org.slf4j.Marker - - -fun setUpBouncyCastleForSshj() { - // Replace the Android BC provider with the Java BouncyCastle provider since the former does - // not include all the required algorithms. - // 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) - // May be needed on Android Pie+ as per https://stackoverflow.com/a/57897224/297261 - try { - Class.forName("sun.security.jca.Providers") - } catch (e: ClassNotFoundException) { - } - Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1) - } - d { "JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}" } -} - -private abstract class AbstractLogger(private val name: String) : Logger { - - abstract fun t(message: String, t: Throwable? = null, vararg args: Any?) - abstract fun d(message: String, t: Throwable? = null, vararg args: Any?) - abstract fun i(message: String, t: Throwable? = null, vararg args: Any?) - abstract fun w(message: String, t: Throwable? = null, vararg args: Any?) - abstract fun e(message: String, t: Throwable? = null, vararg args: Any?) - - override fun getName() = name - - override fun isTraceEnabled(marker: Marker?): Boolean = isTraceEnabled - override fun isDebugEnabled(marker: Marker?): Boolean = isDebugEnabled - override fun isInfoEnabled(marker: Marker?): Boolean = isInfoEnabled - override fun isWarnEnabled(marker: Marker?): Boolean = isWarnEnabled - override fun isErrorEnabled(marker: Marker?): Boolean = isErrorEnabled - - override fun trace(msg: String) = t(msg) - override fun trace(format: String, arg: Any?) = t(format, null, arg) - override fun trace(format: String, arg1: Any?, arg2: Any?) = t(format, null, arg1, arg2) - override fun trace(format: String, vararg arguments: Any?) = t(format, null, *arguments) - override fun trace(msg: String, t: Throwable?) = t(msg, t) - override fun trace(marker: Marker, msg: String) = trace(msg) - override fun trace(marker: Marker?, format: String, arg: Any?) = trace(format, arg) - override fun trace(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = - trace(format, arg1, arg2) - - override fun trace(marker: Marker?, format: String, vararg arguments: Any?) = - trace(format, *arguments) - - override fun trace(marker: Marker?, msg: String, t: Throwable?) = trace(msg, t) - - override fun debug(msg: String) = d(msg) - override fun debug(format: String, arg: Any?) = d(format, null, arg) - override fun debug(format: String, arg1: Any?, arg2: Any?) = d(format, null, arg1, arg2) - override fun debug(format: String, vararg arguments: Any?) = d(format, null, *arguments) - override fun debug(msg: String, t: Throwable?) = d(msg, t) - override fun debug(marker: Marker, msg: String) = debug(msg) - override fun debug(marker: Marker?, format: String, arg: Any?) = debug(format, arg) - override fun debug(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = - debug(format, arg1, arg2) - - override fun debug(marker: Marker?, format: String, vararg arguments: Any?) = - debug(format, *arguments) - - override fun debug(marker: Marker?, msg: String, t: Throwable?) = debug(msg, t) - - override fun info(msg: String) = i(msg) - override fun info(format: String, arg: Any?) = i(format, null, arg) - override fun info(format: String, arg1: Any?, arg2: Any?) = i(format, null, arg1, arg2) - override fun info(format: String, vararg arguments: Any?) = i(format, null, *arguments) - override fun info(msg: String, t: Throwable?) = i(msg, t) - override fun info(marker: Marker, msg: String) = info(msg) - override fun info(marker: Marker?, format: String, arg: Any?) = info(format, arg) - override fun info(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = - info(format, arg1, arg2) - - override fun info(marker: Marker?, format: String, vararg arguments: Any?) = - info(format, *arguments) - - override fun info(marker: Marker?, msg: String, t: Throwable?) = info(msg, t) - - override fun warn(msg: String) = w(msg) - override fun warn(format: String, arg: Any?) = w(format, null, arg) - override fun warn(format: String, arg1: Any?, arg2: Any?) = w(format, null, arg1, arg2) - override fun warn(format: String, vararg arguments: Any?) = w(format, null, *arguments) - override fun warn(msg: String, t: Throwable?) = w(msg, t) - override fun warn(marker: Marker, msg: String) = warn(msg) - override fun warn(marker: Marker?, format: String, arg: Any?) = warn(format, arg) - override fun warn(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = - warn(format, arg1, arg2) - - override fun warn(marker: Marker?, format: String, vararg arguments: Any?) = - warn(format, *arguments) - - override fun warn(marker: Marker?, msg: String, t: Throwable?) = warn(msg, t) - - override fun error(msg: String) = e(msg) - override fun error(format: String, arg: Any?) = e(format, null, arg) - override fun error(format: String, arg1: Any?, arg2: Any?) = e(format, null, arg1, arg2) - override fun error(format: String, vararg arguments: Any?) = e(format, null, *arguments) - override fun error(msg: String, t: Throwable?) = e(msg, t) - override fun error(marker: Marker, msg: String) = error(msg) - override fun error(marker: Marker?, format: String, arg: Any?) = error(format, arg) - override fun error(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = - error(format, arg1, arg2) - - override fun error(marker: Marker?, format: String, vararg arguments: Any?) = - error(format, *arguments) - - override fun error(marker: Marker?, msg: String, t: Throwable?) = error(msg, t) -} - -object TimberLoggerFactory : LoggerFactory { - private class TimberLogger(name: String) : AbstractLogger(name) { - - // We defer the log level checks to Timber. - override fun isTraceEnabled() = true - override fun isDebugEnabled() = true - override fun isInfoEnabled() = true - override fun isWarnEnabled() = true - override fun isErrorEnabled() = true - - // Replace slf4j's "{}" format string style with standard Java's "%s". - // The supposedly redundant escape on the } is not redundant. - @Suppress("RegExpRedundantEscape") - private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s") - - override fun t(message: String, t: Throwable?, vararg args: Any?) { - Timber.tag(name).v(t, message.fix(), *args) - } - - override fun d(message: String, t: Throwable?, vararg args: Any?) { - Timber.tag(name).d(t, message.fix(), *args) - } - - override fun i(message: String, t: Throwable?, vararg args: Any?) { - Timber.tag(name).i(t, message.fix(), *args) - } - - override fun w(message: String, t: Throwable?, vararg args: Any?) { - Timber.tag(name).w(t, message.fix(), *args) - } - - override fun e(message: String, t: Throwable?, vararg args: Any?) { - Timber.tag(name).e(t, message.fix(), *args) - } - } - - override fun getLogger(name: String): Logger { - return TimberLogger(name) - } - - override fun getLogger(clazz: Class<*>): Logger { - return TimberLogger(clazz.name) - } - -} - -class SshjConfig : ConfigImpl() { - - init { - loggerFactory = TimberLoggerFactory - keepAliveProvider = KeepAliveProvider.HEARTBEAT - version = "OpenSSH_8.2p1 Ubuntu-4ubuntu0.1" - - initKeyExchangeFactories() - initKeyAlgorithms() - initRandomFactory() - initFileKeyProviderFactories() - initCipherFactories() - initCompressionFactories() - initMACFactories() - } - - private fun initKeyExchangeFactories() { - keyExchangeFactories = listOf( - Curve25519SHA256.Factory(), - FactoryLibSsh(), - ECDHNistP.Factory521(), - ECDHNistP.Factory384(), - ECDHNistP.Factory256(), - DHGexSHA256.Factory(), - // Sends "ext-info-c" with the list of key exchange algorithms. This is needed to get - // rsa-sha2-* key types to work with some servers (e.g. GitHub). - ExtInfoClientFactory(), - ) - } - - private fun initKeyAlgorithms() { - keyAlgorithms = listOf( - KeyAlgorithms.SSHRSACertV01(), - KeyAlgorithms.EdDSA25519(), - KeyAlgorithms.RSASHA512(), - KeyAlgorithms.RSASHA256(), - KeyAlgorithms.ECDSASHANistp521(), - KeyAlgorithms.ECDSASHANistp384(), - KeyAlgorithms.ECDSASHANistp256(), - KeyAlgorithms.SSHRSA(), - ) - } - - private fun initRandomFactory() { - randomFactory = SingletonRandomFactory(JCERandom.Factory()) - } - - private fun initFileKeyProviderFactories() { - fileKeyProviderFactories = listOf( - OpenSSHKeyV1KeyFile.Factory(), - PKCS8KeyFile.Factory(), - PKCS5KeyFile.Factory(), - OpenSSHKeyFile.Factory(), - PuTTYKeyFile.Factory(), - ) - } - - - private fun initCipherFactories() { - cipherFactories = listOf( - BlockCiphers.AES256CTR(), - BlockCiphers.AES192CTR(), - BlockCiphers.AES128CTR(), - ) - } - - private fun initMACFactories() { - macFactories = listOf( - Macs.HMACSHA2512Etm(), - Macs.HMACSHA2256Etm(), - Macs.HMACSHA2512(), - Macs.HMACSHA2256(), - ) - } - - private fun initCompressionFactories() { - compressionFactories = listOf( - NoneCompression.Factory(), - ) - } -} 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 deleted file mode 100644 index 85b5f753..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/config/SshjSessionFactory.kt +++ /dev/null @@ -1,180 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.config - -import android.util.Base64 -import com.github.ajalt.timberkt.d -import com.github.ajalt.timberkt.w -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 -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.DisconnectReason -import net.schmizz.sshj.common.SSHException -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 - -sealed class SshAuthData { - class Password(val passwordFinder: InteractivePasswordFinder) : SshAuthData() - class PublicKeyFile(val keyFile: File, val passphraseFinder: InteractivePasswordFinder) : SshAuthData() -} - -abstract class InteractivePasswordFinder : PasswordFinder { - - private var isRetry = false - - abstract fun askForPassword(cont: Continuation, isRetry: Boolean) - - final override fun reqPassword(resource: Resource<*>?): CharArray { - val password = runBlocking(Dispatchers.Main) { - suspendCoroutine { cont -> - askForPassword(cont, isRetry) - } - } - isRetry = true - return password?.toCharArray() - ?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER) - } - - final override fun shouldRetry(resource: Resource<*>?) = true -} - -class SshjSessionFactory(private val authData: SshAuthData, private val hostKeyFile: File) : SshSessionFactory() { - - private var currentSession: SshjSession? = null - - override fun getSession(uri: URIish, credentialsProvider: CredentialsProvider?, fs: FS?, tms: Int): RemoteSession { - return currentSession ?: SshjSession(uri, uri.user, authData, hostKeyFile).connect().also { - d { "New SSH connection created" } - currentSession = it - } - } - - fun close() { - currentSession?.close() - } -} - -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(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 - - private val uri = if (uri.host.contains('@')) { - // URIish's String constructor cannot handle '@' in the user part of the URI and the URL - // constructor can't be used since Java's URL does not recognize the ssh scheme. We thus - // need to patch everything up ourselves. - d { "Before fixup: user=${uri.user}, host=${uri.host}" } - val userPlusHost = "${uri.user}@${uri.host}" - val realUser = userPlusHost.substringBeforeLast('@') - val realHost = userPlusHost.substringAfterLast('@') - uri.setUser(realUser).setHost(realHost).also { d { "After fixup: user=${it.user}, host=${it.host}" } } - } else { - uri - } - - fun connect(): SshjSession { - ssh = SSHClient(SshjConfig()) - 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 command" } - disconnect() - } - val session = ssh.startSession() - currentCommand = session - return SshjProcess(session.exec(commandName), timeout.toLong()) - } - - /** - * Kills the current command if one is running and returns the session into a state where `exec` - * can be called. - * - * Note that this does *not* disconnect the session. Unfortunately, the function has to be - * called `disconnect` to override the corresponding abstract function in `RemoteSession`. - */ - override fun disconnect() { - currentCommand?.close() - currentCommand = null - } - - fun close() { - disconnect() - 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/git/operation/CredentialFinder.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/CredentialFinder.kt index 423ceb80..6b6b8ea8 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/operation/CredentialFinder.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/operation/CredentialFinder.kt @@ -11,7 +11,7 @@ import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout import com.zeapo.pwdstore.R import com.zeapo.pwdstore.git.config.ConnectionMode -import com.zeapo.pwdstore.git.config.InteractivePasswordFinder +import com.zeapo.pwdstore.git.sshj.InteractivePasswordFinder import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.getEncryptedPrefs import com.zeapo.pwdstore.utils.requestInputFocusOnView diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt index 881b8807..ae4674fe 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt @@ -8,7 +8,6 @@ import android.content.Intent import androidx.annotation.CallSuper import androidx.core.content.edit import androidx.fragment.app.FragmentActivity -import androidx.preference.PreferenceManager import com.github.ajalt.timberkt.Timber.d import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.zeapo.pwdstore.R @@ -16,13 +15,13 @@ import com.zeapo.pwdstore.UserPreference import com.zeapo.pwdstore.git.ErrorMessages import com.zeapo.pwdstore.git.config.ConnectionMode import com.zeapo.pwdstore.git.config.GitSettings -import com.zeapo.pwdstore.git.config.InteractivePasswordFinder -import com.zeapo.pwdstore.git.config.SshApiSessionFactory -import com.zeapo.pwdstore.git.config.SshAuthData -import com.zeapo.pwdstore.git.config.SshjSessionFactory +import com.zeapo.pwdstore.git.sshj.InteractivePasswordFinder +import com.zeapo.pwdstore.git.sshj.SshAuthData +import com.zeapo.pwdstore.git.sshj.SshjSessionFactory import com.zeapo.pwdstore.utils.PasswordRepository import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.getEncryptedPrefs +import com.zeapo.pwdstore.utils.sharedPrefs import java.io.File import net.schmizz.sshj.userauth.password.PasswordFinder import org.eclipse.jgit.api.Git @@ -84,8 +83,9 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: Fragment return this } - private fun withOpenKeychainAuthentication(identity: SshApiSessionFactory.ApiIdentity?): GitOperation { - SshSessionFactory.setInstance(SshApiSessionFactory(identity)) + private fun withOpenKeychainAuthentication(activity: FragmentActivity): GitOperation { + val sessionFactory = SshjSessionFactory(SshAuthData.OpenKeychain(activity), hostKeyFile) + SshSessionFactory.setInstance(sessionFactory) this.provider = null return this } @@ -116,7 +116,6 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: Fragment suspend fun executeAfterAuthentication( connectionMode: ConnectionMode, - identity: SshApiSessionFactory.ApiIdentity? ) { when (connectionMode) { ConnectionMode.SshKey -> if (!sshKeyFile.exists()) { @@ -137,7 +136,7 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: Fragment withPublicKeyAuthentication( CredentialFinder(callingActivity, connectionMode)).execute() } - ConnectionMode.OpenKeychain -> withOpenKeychainAuthentication(identity).execute() + ConnectionMode.OpenKeychain -> withOpenKeychainAuthentication(callingActivity).execute() ConnectionMode.Password -> withPasswordAuthentication( CredentialFinder(callingActivity, connectionMode)).execute() ConnectionMode.None -> execute() @@ -150,17 +149,10 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: Fragment @CallSuper open fun onError(err: Exception) { // Clear various auth related fields on failure - when (SshSessionFactory.getInstance()) { - is SshApiSessionFactory -> { - PreferenceManager.getDefaultSharedPreferences(callingActivity.applicationContext) - .edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) } - } - is SshjSessionFactory -> { - callingActivity.getEncryptedPrefs("git_operation").edit { - remove(PreferenceKeys.HTTPS_PASSWORD) - } - } + callingActivity.getEncryptedPrefs("git_operation").edit { + remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) } + callingActivity.sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) } d(err) MaterialAlertDialogBuilder(callingActivity) .setTitle(callingActivity.resources.getString(R.string.jgit_error_dialog_title)) diff --git a/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainKeyProvider.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainKeyProvider.kt new file mode 100644 index 00000000..cecf7505 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainKeyProvider.kt @@ -0,0 +1,212 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.git.sshj + +import android.app.PendingIntent +import android.content.Intent +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.edit +import androidx.fragment.app.FragmentActivity +import com.github.ajalt.timberkt.d +import com.zeapo.pwdstore.utils.OPENPGP_PROVIDER +import com.zeapo.pwdstore.utils.PreferenceKeys +import com.zeapo.pwdstore.utils.sharedPrefs +import java.io.Closeable +import java.security.PublicKey +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.schmizz.sshj.common.Base64 +import net.schmizz.sshj.common.Buffer +import net.schmizz.sshj.common.DisconnectReason +import net.schmizz.sshj.common.KeyType +import net.schmizz.sshj.userauth.UserAuthException +import net.schmizz.sshj.userauth.keyprovider.KeyProvider +import org.openintents.ssh.authentication.ISshAuthenticationService +import org.openintents.ssh.authentication.SshAuthenticationApi +import org.openintents.ssh.authentication.SshAuthenticationApiError +import org.openintents.ssh.authentication.SshAuthenticationConnection +import org.openintents.ssh.authentication.request.KeySelectionRequest +import org.openintents.ssh.authentication.request.Request +import org.openintents.ssh.authentication.request.SigningRequest +import org.openintents.ssh.authentication.request.SshPublicKeyRequest +import org.openintents.ssh.authentication.response.KeySelectionResponse +import org.openintents.ssh.authentication.response.Response +import org.openintents.ssh.authentication.response.SigningResponse +import org.openintents.ssh.authentication.response.SshPublicKeyResponse + +class OpenKeychainKeyProvider private constructor(private val activity: FragmentActivity) : KeyProvider, Closeable { + + companion object { + + suspend fun prepareAndUse(activity: FragmentActivity, block: (provider: OpenKeychainKeyProvider) -> Unit) { + withContext(Dispatchers.Main){ + OpenKeychainKeyProvider(activity) + }.prepareAndUse(block) + } + } + + private sealed class ApiResponse { + data class Success(val response: Response) : ApiResponse() + data class GeneralError(val exception: Exception) : ApiResponse() + data class NoSuchKey(val exception: Exception) : ApiResponse() + } + + private val context = activity.applicationContext + private val sshServiceConnection = SshAuthenticationConnection(context, OPENPGP_PROVIDER) + private val preferences = context.sharedPrefs + private val continueAfterUserInteraction = + activity.registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> + currentCont?.let { cont -> + currentCont = null + val data = result.data + if (data != null) + cont.resume(data) + else + cont.resumeWithException(UserAuthException(DisconnectReason.AUTH_CANCELLED_BY_USER)) + } + } + + private lateinit var sshServiceApi: SshAuthenticationApi + + private var currentCont: Continuation? = null + private var keyId + get() = preferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) + set(value) { + preferences.edit { + putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, value) + } + } + private var publicKey: PublicKey? = null + private var privateKey: OpenKeychainPrivateKey? = null + + private suspend fun prepareAndUse(block: (provider: OpenKeychainKeyProvider) -> Unit) { + prepare() + use(block) + } + + private suspend fun prepare() { + sshServiceApi = suspendCoroutine { cont -> + sshServiceConnection.connect(object : SshAuthenticationConnection.OnBound { + override fun onBound(sshAgent: ISshAuthenticationService) { + d { "Bound to SshAuthenticationApi: $OPENPGP_PROVIDER" } + cont.resume(SshAuthenticationApi(context, sshAgent)) + } + + override fun onError() { + throw UserAuthException(DisconnectReason.UNKNOWN, "OpenKeychain service unavailable") + } + }) + } + + if (keyId == null) { + selectKey() + } + check(keyId != null) + fetchPublicKey() + makePrivateKey() + } + + private suspend fun fetchPublicKey(isRetry: Boolean = false) { + when (val sshPublicKeyResponse = executeApiRequest(SshPublicKeyRequest(keyId))) { + is ApiResponse.Success -> { + val response = sshPublicKeyResponse.response as SshPublicKeyResponse + val sshPublicKey = response.sshPublicKey!! + val sshKeyParts = sshPublicKey.split("""\s+""".toRegex()) + check(sshKeyParts.size >= 2) { "OpenKeychain API returned invalid SSH key" } + @Suppress("BlockingMethodInNonBlockingContext") + publicKey = Buffer.PlainBuffer(Base64.decode(sshKeyParts[1])).readPublicKey() + } + is ApiResponse.NoSuchKey -> if (isRetry) { + throw sshPublicKeyResponse.exception + } else { + // Allow the user to reselect an authentication key and retry + selectKey() + fetchPublicKey(true) + } + is ApiResponse.GeneralError -> throw sshPublicKeyResponse.exception + } + } + + private suspend fun selectKey() { + when (val keySelectionResponse = executeApiRequest(KeySelectionRequest())) { + is ApiResponse.Success -> keyId = (keySelectionResponse.response as KeySelectionResponse).keyId + is ApiResponse.GeneralError -> throw keySelectionResponse.exception + is ApiResponse.NoSuchKey -> throw keySelectionResponse.exception + } + } + + private suspend fun executeApiRequest(request: Request, resultOfUserInteraction: Intent? = null): ApiResponse { + d { "executeRequest($request) called" } + val result = withContext(Dispatchers.Main) { + // If the request required user interaction, the data returned from the PendingIntent + // is used as the real request. + sshServiceApi.executeApi(resultOfUserInteraction ?: request.toIntent())!! + } + return parseResult(request, result).also { + d { "executeRequest($request): $it" } + } + } + + private suspend fun parseResult(request: Request, result: Intent): ApiResponse { + return when (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, SshAuthenticationApi.RESULT_CODE_ERROR)) { + SshAuthenticationApi.RESULT_CODE_SUCCESS -> { + ApiResponse.Success(when (request) { + is KeySelectionRequest -> KeySelectionResponse(result) + is SshPublicKeyRequest -> SshPublicKeyResponse(result) + is SigningRequest -> SigningResponse(result) + else -> throw IllegalArgumentException("Unsupported OpenKeychain request type") + }) + } + SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val pendingIntent: PendingIntent = result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT)!! + val resultOfUserInteraction: Intent = withContext(Dispatchers.Main) { + suspendCoroutine { cont -> + currentCont = cont + continueAfterUserInteraction.launch(IntentSenderRequest.Builder(pendingIntent).build()) + } + } + executeApiRequest(request, resultOfUserInteraction) + } + else -> { + val error = result.getParcelableExtra(SshAuthenticationApi.EXTRA_ERROR) + val exception = UserAuthException(DisconnectReason.UNKNOWN, "Request ${request::class.simpleName} failed: ${error?.message}") + when (error?.error) { + SshAuthenticationApiError.NO_AUTH_KEY, SshAuthenticationApiError.NO_SUCH_KEY -> ApiResponse.NoSuchKey(exception) + else -> ApiResponse.GeneralError(exception) + } + } + } + } + + private fun makePrivateKey() { + check(keyId != null && publicKey != null) + privateKey = object : OpenKeychainPrivateKey { + override suspend fun sign(challenge: ByteArray, hashAlgorithm: Int) = + when (val signingResponse = executeApiRequest(SigningRequest(challenge, keyId, hashAlgorithm))) { + is ApiResponse.Success -> (signingResponse.response as SigningResponse).signature + is ApiResponse.GeneralError -> throw signingResponse.exception + is ApiResponse.NoSuchKey -> throw signingResponse.exception + } + + override fun getAlgorithm() = publicKey!!.algorithm + } + } + + override fun close() { + continueAfterUserInteraction.unregister() + sshServiceConnection.disconnect() + } + + override fun getPrivate() = privateKey + + override fun getPublic() = publicKey + + override fun getType() = KeyType.fromKey(publicKey) +} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt new file mode 100644 index 00000000..97b587fd --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt @@ -0,0 +1,93 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.git.sshj + +import com.hierynomus.sshj.key.KeyAlgorithm +import java.io.ByteArrayOutputStream +import java.security.PrivateKey +import kotlinx.coroutines.runBlocking +import net.schmizz.sshj.common.Buffer +import net.schmizz.sshj.common.Factory +import net.schmizz.sshj.signature.Signature +import org.openintents.ssh.authentication.SshAuthenticationApi + +interface OpenKeychainPrivateKey : PrivateKey { + + suspend fun sign(challenge: ByteArray, hashAlgorithm: Int): ByteArray + + override fun getFormat() = null + override fun getEncoded() = null +} + +class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named) : Factory.Named by factory { + + override fun create() = OpenKeychainWrappedKeyAlgorithm(factory.create()) +} + +class OpenKeychainWrappedKeyAlgorithm(private val keyAlgorithm: KeyAlgorithm) : KeyAlgorithm by keyAlgorithm { + + private val hashAlgorithm = when (keyAlgorithm.keyAlgorithm) { + "rsa-sha2-512" -> SshAuthenticationApi.SHA512 + "rsa-sha2-256" -> SshAuthenticationApi.SHA256 + "ssh-rsa", "ssh-rsa-cert-v01@openssh.com" -> SshAuthenticationApi.SHA1 + // Other algorithms don't use this value, but it has to be valid. + else -> SshAuthenticationApi.SHA512 + } + + override fun newSignature() = OpenKeychainWrappedSignature(keyAlgorithm.newSignature(), hashAlgorithm) +} + +class OpenKeychainWrappedSignature(private val wrappedSignature: Signature, private val hashAlgorithm: Int) : Signature by wrappedSignature { + + private val data = ByteArrayOutputStream() + + private var bridgedPrivateKey: OpenKeychainPrivateKey? = null + + override fun initSign(prvkey: PrivateKey?) { + if (prvkey is OpenKeychainPrivateKey) { + bridgedPrivateKey = prvkey + } else { + wrappedSignature.initSign(prvkey) + } + } + + override fun update(H: ByteArray?) { + if (bridgedPrivateKey != null) { + data.write(H!!) + } else { + wrappedSignature.update(H) + } + } + + override fun update(H: ByteArray?, off: Int, len: Int) { + if (bridgedPrivateKey != null) { + data.write(H!!, off, len) + } else { + wrappedSignature.update(H, off, len) + } + } + + override fun sign(): ByteArray? = if (bridgedPrivateKey != null) { + runBlocking { + bridgedPrivateKey!!.sign(data.toByteArray(), hashAlgorithm) + } + } else { + wrappedSignature.sign() + } + + override fun encode(signature: ByteArray?): ByteArray? = if (bridgedPrivateKey != null) { + require(signature != null) { "OpenKeychain signature must not be null" } + val encodedSignature = Buffer.PlainBuffer(signature) + // We need to drop the algorithm name and extract the raw signature since SSHJ adds the name + // later. + encodedSignature.readString() + encodedSignature.readBytes().also { + bridgedPrivateKey = null + data.reset() + } + } else { + wrappedSignature.encode(signature) + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt new file mode 100644 index 00000000..bf454cd5 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt @@ -0,0 +1,277 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.git.sshj + +import com.github.ajalt.timberkt.Timber +import com.github.ajalt.timberkt.d +import com.hierynomus.sshj.key.KeyAlgorithms +import com.hierynomus.sshj.transport.cipher.BlockCiphers +import com.hierynomus.sshj.transport.kex.ExtInfoClientFactory +import com.hierynomus.sshj.transport.mac.Macs +import com.hierynomus.sshj.userauth.keyprovider.OpenSSHKeyV1KeyFile +import java.security.Security +import net.schmizz.keepalive.KeepAliveProvider +import net.schmizz.sshj.ConfigImpl +import net.schmizz.sshj.common.LoggerFactory +import net.schmizz.sshj.transport.compression.NoneCompression +import net.schmizz.sshj.transport.kex.Curve25519SHA256 +import net.schmizz.sshj.transport.kex.Curve25519SHA256.FactoryLibSsh +import net.schmizz.sshj.transport.kex.DHGexSHA256 +import net.schmizz.sshj.transport.kex.ECDHNistP +import net.schmizz.sshj.transport.random.JCERandom +import net.schmizz.sshj.transport.random.SingletonRandomFactory +import net.schmizz.sshj.userauth.keyprovider.OpenSSHKeyFile +import net.schmizz.sshj.userauth.keyprovider.PKCS5KeyFile +import net.schmizz.sshj.userauth.keyprovider.PKCS8KeyFile +import net.schmizz.sshj.userauth.keyprovider.PuTTYKeyFile +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.slf4j.Logger +import org.slf4j.Marker + + +fun setUpBouncyCastleForSshj() { + // Replace the Android BC provider with the Java BouncyCastle provider since the former does + // not include all the required algorithms. + // 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) + // May be needed on Android Pie+ as per https://stackoverflow.com/a/57897224/297261 + try { + Class.forName("sun.security.jca.Providers") + } catch (e: ClassNotFoundException) { + } + Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1) + } + d { "JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}" } +} + +private abstract class AbstractLogger(private val name: String) : Logger { + + abstract fun t(message: String, t: Throwable? = null, vararg args: Any?) + abstract fun d(message: String, t: Throwable? = null, vararg args: Any?) + abstract fun i(message: String, t: Throwable? = null, vararg args: Any?) + abstract fun w(message: String, t: Throwable? = null, vararg args: Any?) + abstract fun e(message: String, t: Throwable? = null, vararg args: Any?) + + override fun getName() = name + + override fun isTraceEnabled(marker: Marker?): Boolean = isTraceEnabled + override fun isDebugEnabled(marker: Marker?): Boolean = isDebugEnabled + override fun isInfoEnabled(marker: Marker?): Boolean = isInfoEnabled + override fun isWarnEnabled(marker: Marker?): Boolean = isWarnEnabled + override fun isErrorEnabled(marker: Marker?): Boolean = isErrorEnabled + + override fun trace(msg: String) = t(msg) + override fun trace(format: String, arg: Any?) = t(format, null, arg) + override fun trace(format: String, arg1: Any?, arg2: Any?) = t(format, null, arg1, arg2) + override fun trace(format: String, vararg arguments: Any?) = t(format, null, *arguments) + override fun trace(msg: String, t: Throwable?) = t(msg, t) + override fun trace(marker: Marker, msg: String) = trace(msg) + override fun trace(marker: Marker?, format: String, arg: Any?) = trace(format, arg) + override fun trace(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = + trace(format, arg1, arg2) + + override fun trace(marker: Marker?, format: String, vararg arguments: Any?) = + trace(format, *arguments) + + override fun trace(marker: Marker?, msg: String, t: Throwable?) = trace(msg, t) + + override fun debug(msg: String) = d(msg) + override fun debug(format: String, arg: Any?) = d(format, null, arg) + override fun debug(format: String, arg1: Any?, arg2: Any?) = d(format, null, arg1, arg2) + override fun debug(format: String, vararg arguments: Any?) = d(format, null, *arguments) + override fun debug(msg: String, t: Throwable?) = d(msg, t) + override fun debug(marker: Marker, msg: String) = debug(msg) + override fun debug(marker: Marker?, format: String, arg: Any?) = debug(format, arg) + override fun debug(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = + debug(format, arg1, arg2) + + override fun debug(marker: Marker?, format: String, vararg arguments: Any?) = + debug(format, *arguments) + + override fun debug(marker: Marker?, msg: String, t: Throwable?) = debug(msg, t) + + override fun info(msg: String) = i(msg) + override fun info(format: String, arg: Any?) = i(format, null, arg) + override fun info(format: String, arg1: Any?, arg2: Any?) = i(format, null, arg1, arg2) + override fun info(format: String, vararg arguments: Any?) = i(format, null, *arguments) + override fun info(msg: String, t: Throwable?) = i(msg, t) + override fun info(marker: Marker, msg: String) = info(msg) + override fun info(marker: Marker?, format: String, arg: Any?) = info(format, arg) + override fun info(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = + info(format, arg1, arg2) + + override fun info(marker: Marker?, format: String, vararg arguments: Any?) = + info(format, *arguments) + + override fun info(marker: Marker?, msg: String, t: Throwable?) = info(msg, t) + + override fun warn(msg: String) = w(msg) + override fun warn(format: String, arg: Any?) = w(format, null, arg) + override fun warn(format: String, arg1: Any?, arg2: Any?) = w(format, null, arg1, arg2) + override fun warn(format: String, vararg arguments: Any?) = w(format, null, *arguments) + override fun warn(msg: String, t: Throwable?) = w(msg, t) + override fun warn(marker: Marker, msg: String) = warn(msg) + override fun warn(marker: Marker?, format: String, arg: Any?) = warn(format, arg) + override fun warn(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = + warn(format, arg1, arg2) + + override fun warn(marker: Marker?, format: String, vararg arguments: Any?) = + warn(format, *arguments) + + override fun warn(marker: Marker?, msg: String, t: Throwable?) = warn(msg, t) + + override fun error(msg: String) = e(msg) + override fun error(format: String, arg: Any?) = e(format, null, arg) + override fun error(format: String, arg1: Any?, arg2: Any?) = e(format, null, arg1, arg2) + override fun error(format: String, vararg arguments: Any?) = e(format, null, *arguments) + override fun error(msg: String, t: Throwable?) = e(msg, t) + override fun error(marker: Marker, msg: String) = error(msg) + override fun error(marker: Marker?, format: String, arg: Any?) = error(format, arg) + override fun error(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = + error(format, arg1, arg2) + + override fun error(marker: Marker?, format: String, vararg arguments: Any?) = + error(format, *arguments) + + override fun error(marker: Marker?, msg: String, t: Throwable?) = error(msg, t) +} + +object TimberLoggerFactory : LoggerFactory { + private class TimberLogger(name: String) : AbstractLogger(name) { + + // We defer the log level checks to Timber. + override fun isTraceEnabled() = true + override fun isDebugEnabled() = true + override fun isInfoEnabled() = true + override fun isWarnEnabled() = true + override fun isErrorEnabled() = true + + // Replace slf4j's "{}" format string style with standard Java's "%s". + // The supposedly redundant escape on the } is not redundant. + @Suppress("RegExpRedundantEscape") + private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s") + + override fun t(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).v(t, message.fix(), *args) + } + + override fun d(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).d(t, message.fix(), *args) + } + + override fun i(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).i(t, message.fix(), *args) + } + + override fun w(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).w(t, message.fix(), *args) + } + + override fun e(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).e(t, message.fix(), *args) + } + } + + override fun getLogger(name: String): Logger { + return TimberLogger(name) + } + + override fun getLogger(clazz: Class<*>): Logger { + return TimberLogger(clazz.name) + } + +} + +class SshjConfig : ConfigImpl() { + + init { + loggerFactory = TimberLoggerFactory + keepAliveProvider = KeepAliveProvider.HEARTBEAT + version = "OpenSSH_8.2p1 Ubuntu-4ubuntu0.1" + + initKeyExchangeFactories() + initKeyAlgorithms() + initRandomFactory() + initFileKeyProviderFactories() + initCipherFactories() + initCompressionFactories() + initMACFactories() + } + + private fun initKeyExchangeFactories() { + keyExchangeFactories = listOf( + Curve25519SHA256.Factory(), + FactoryLibSsh(), + ECDHNistP.Factory521(), + ECDHNistP.Factory384(), + ECDHNistP.Factory256(), + DHGexSHA256.Factory(), + // Sends "ext-info-c" with the list of key exchange algorithms. This is needed to get + // rsa-sha2-* key types to work with some servers (e.g. GitHub). + ExtInfoClientFactory(), + ) + } + + private fun initKeyAlgorithms() { + keyAlgorithms = listOf( + KeyAlgorithms.SSHRSACertV01(), + KeyAlgorithms.EdDSA25519(), + KeyAlgorithms.RSASHA512(), + KeyAlgorithms.RSASHA256(), + KeyAlgorithms.ECDSASHANistp521(), + KeyAlgorithms.ECDSASHANistp384(), + KeyAlgorithms.ECDSASHANistp256(), + KeyAlgorithms.SSHRSA(), + ).map { + OpenKeychainWrappedKeyAlgorithmFactory(it) + } + } + + private fun initRandomFactory() { + randomFactory = SingletonRandomFactory(JCERandom.Factory()) + } + + private fun initFileKeyProviderFactories() { + fileKeyProviderFactories = listOf( + OpenSSHKeyV1KeyFile.Factory(), + PKCS8KeyFile.Factory(), + PKCS5KeyFile.Factory(), + OpenSSHKeyFile.Factory(), + PuTTYKeyFile.Factory(), + ) + } + + + private fun initCipherFactories() { + cipherFactories = listOf( + BlockCiphers.AES256CTR(), + BlockCiphers.AES192CTR(), + BlockCiphers.AES128CTR(), + ) + } + + private fun initMACFactories() { + macFactories = listOf( + Macs.HMACSHA2512Etm(), + Macs.HMACSHA2256Etm(), + Macs.HMACSHA2512(), + Macs.HMACSHA2256(), + ) + } + + private fun initCompressionFactories() { + compressionFactories = listOf( + NoneCompression.Factory(), + ) + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjSessionFactory.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjSessionFactory.kt new file mode 100644 index 00000000..05428e41 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjSessionFactory.kt @@ -0,0 +1,189 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.git.sshj + +import android.util.Base64 +import androidx.fragment.app.FragmentActivity +import com.github.ajalt.timberkt.d +import com.github.ajalt.timberkt.w +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 +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.DisconnectReason +import net.schmizz.sshj.common.SSHException +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 + +sealed class SshAuthData { + class Password(val passwordFinder: InteractivePasswordFinder) : SshAuthData() + class PublicKeyFile(val keyFile: File, val passphraseFinder: InteractivePasswordFinder) : SshAuthData() + class OpenKeychain(val activity: FragmentActivity) : SshAuthData() +} + +abstract class InteractivePasswordFinder : PasswordFinder { + + private var isRetry = false + + abstract fun askForPassword(cont: Continuation, isRetry: Boolean) + + final override fun reqPassword(resource: Resource<*>?): CharArray { + val password = runBlocking(Dispatchers.Main) { + suspendCoroutine { cont -> + askForPassword(cont, isRetry) + } + } + isRetry = true + return password?.toCharArray() + ?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER) + } + + final override fun shouldRetry(resource: Resource<*>?) = true +} + +class SshjSessionFactory(private val authData: SshAuthData, private val hostKeyFile: File) : SshSessionFactory() { + + private var currentSession: SshjSession? = null + + override fun getSession(uri: URIish, credentialsProvider: CredentialsProvider?, fs: FS?, tms: Int): RemoteSession { + return currentSession ?: SshjSession(uri, uri.user, authData, hostKeyFile).connect().also { + d { "New SSH connection created" } + currentSession = it + } + } + + fun close() { + currentSession?.close() + } +} + +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(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 + + private val uri = if (uri.host.contains('@')) { + // URIish's String constructor cannot handle '@' in the user part of the URI and the URL + // constructor can't be used since Java's URL does not recognize the ssh scheme. We thus + // need to patch everything up ourselves. + d { "Before fixup: user=${uri.user}, host=${uri.host}" } + val userPlusHost = "${uri.user}@${uri.host}" + val realUser = userPlusHost.substringBeforeLast('@') + val realHost = userPlusHost.substringAfterLast('@') + uri.setUser(realUser).setHost(realHost).also { d { "After fixup: user=${it.user}, host=${it.host}" } } + } else { + uri + } + + fun connect(): SshjSession { + ssh = SSHClient(SshjConfig()) + 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)) + } + is SshAuthData.OpenKeychain -> { + runBlocking { + OpenKeychainKeyProvider.prepareAndUse(authData.activity) { provider -> + ssh.authPublickey(username, provider) + } + } + } + } + return this + } + + override fun exec(commandName: String?, timeout: Int): Process { + if (currentCommand != null) { + w { "Killing old command" } + disconnect() + } + val session = ssh.startSession() + currentCommand = session + return SshjProcess(session.exec(commandName), timeout.toLong()) + } + + /** + * Kills the current command if one is running and returns the session into a state where `exec` + * can be called. + * + * Note that this does *not* disconnect the session. Unfortunately, the function has to be + * called `disconnect` to override the corresponding abstract function in `RemoteSession`. + */ + override fun disconnect() { + currentCommand?.close() + currentCommand = null + } + + fun close() { + disconnect() + 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 +} -- cgit v1.2.3