aboutsummaryrefslogtreecommitdiffstats
path: root/app/src/main/java/com/zeapo/pwdstore/git/sshj
diff options
context:
space:
mode:
authorFabian Henneke <FabianHenneke@users.noreply.github.com>2020-08-18 22:02:34 +0200
committerGitHub <noreply@github.com>2020-08-18 22:02:34 +0200
commit152d86ec3a951bd6cb797128094d933d2c3a5697 (patch)
tree6f8dcc7157f130e3eb2c018cc2819963e0d6993a /app/src/main/java/com/zeapo/pwdstore/git/sshj
parentbd0d97d242f7e97ae495dd9408c25eed3c208097 (diff)
downloadHardenedPass-152d86ec3a951bd6cb797128094d933d2c3a5697.tar.gz
HardenedPass-152d86ec3a951bd6cb797128094d933d2c3a5697.zip
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>
Diffstat (limited to 'app/src/main/java/com/zeapo/pwdstore/git/sshj')
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainKeyProvider.kt212
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt93
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt277
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjSessionFactory.kt189
4 files changed, 771 insertions, 0 deletions
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/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<String?>, isRetry: Boolean)
+
+ final override fun reqPassword(resource: Resource<*>?): CharArray {
+ val password = runBlocking(Dispatchers.Main) {
+ suspendCoroutine<String?> { 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
+}