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 <me@msfjarvis.dev>
This commit is contained in:
Fabian Henneke
2020-08-18 22:02:34 +02:00
committed by GitHub
parent bd0d97d242
commit 152d86ec3a
10 changed files with 335 additions and 483 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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