diff options
-rw-r--r-- | app/src/main/java/com/zeapo/pwdstore/Application.kt | 2 | ||||
-rw-r--r-- | app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt | 77 | ||||
-rw-r--r-- | app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt | 2 | ||||
-rw-r--r-- | app/src/main/java/com/zeapo/pwdstore/git/config/SshApiSessionFactory.java | 383 | ||||
-rw-r--r-- | app/src/main/java/com/zeapo/pwdstore/git/operation/CredentialFinder.kt | 2 | ||||
-rw-r--r-- | app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt | 30 | ||||
-rw-r--r-- | app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainKeyProvider.kt | 212 | ||||
-rw-r--r-- | app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt | 93 | ||||
-rw-r--r-- | app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt (renamed from app/src/main/java/com/zeapo/pwdstore/git/config/SshjConfig.kt) | 6 | ||||
-rw-r--r-- | app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjSessionFactory.kt (renamed from app/src/main/java/com/zeapo/pwdstore/git/config/SshjSessionFactory.kt) | 11 |
10 files changed, 335 insertions, 483 deletions
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<String> 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/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<Intent>? = 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<SshAuthenticationApiError>(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<KeyAlgorithm>) : Factory.Named<KeyAlgorithm> 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/config/SshjConfig.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt index 6c409329..bf454cd5 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/config/SshjConfig.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt @@ -2,7 +2,7 @@ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. * SPDX-License-Identifier: GPL-3.0-only */ -package com.zeapo.pwdstore.git.config +package com.zeapo.pwdstore.git.sshj import com.github.ajalt.timberkt.Timber import com.github.ajalt.timberkt.d @@ -232,7 +232,9 @@ class SshjConfig : ConfigImpl() { KeyAlgorithms.ECDSASHANistp384(), KeyAlgorithms.ECDSASHANistp256(), KeyAlgorithms.SSHRSA(), - ) + ).map { + OpenKeychainWrappedKeyAlgorithmFactory(it) + } } private fun initRandomFactory() { diff --git a/app/src/main/java/com/zeapo/pwdstore/git/config/SshjSessionFactory.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjSessionFactory.kt index 85b5f753..05428e41 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/config/SshjSessionFactory.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjSessionFactory.kt @@ -2,9 +2,10 @@ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. * SPDX-License-Identifier: GPL-3.0-only */ -package com.zeapo.pwdstore.git.config +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 @@ -37,6 +38,7 @@ 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 { @@ -128,6 +130,13 @@ private class SshjSession(uri: URIish, private val username: String, private val 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 } |