From 97b3577a463966e93d24649ff348fc4bb6825e50 Mon Sep 17 00:00:00 2001 From: Aditya Wasan Date: Sun, 2 Apr 2023 14:04:33 -0400 Subject: [PATCH] Refactor SSHKey into a separate module (#2450) * refactor(ssh): add `ssh` module Signed-off-by: Aditya Wasan * refactor(ssh): add `SSHKey` data class Signed-off-by: Aditya Wasan * refactor(ssh): add `SSHKeyType` enum Signed-off-by: Aditya Wasan * refactor(ssh): add `SSHKeyAlgorithm` class Signed-off-by: Aditya Wasan * refactor(ssh): add class to generate `RSA` key Signed-off-by: Aditya Wasan * chore(ssh): add required dependencies Signed-off-by: Aditya Wasan * refactor(ssh): add `ECDSAKeyGenerator` and remove constants Signed-off-by: Aditya Wasan * refactor(ssh): add utilities Signed-off-by: Aditya Wasan * feat(ssh): add `SSHKeyWriter` Signed-off-by: Aditya Wasan * refactor(ssh): make ssh key generators suspending Signed-off-by: Aditya Wasan * fix(ssh): fix explicit API violations * feat: complete `ED25519KeyWriter` implementation Signed-off-by: Aditya Wasan * factor(ssh/writer): update writer interface Signed-off-by: Aditya Wasan * feat(ssh/provider): add providers for different key types Signed-off-by: Aditya Wasan * feat(ssh): add SSHKeyManager for common key functionality Signed-off-by: Aditya Wasan * feat(ssh): add remaining methods to reach feature parity with old SSH implementation Signed-off-by: Aditya Wasan * wip(app): start using SSHKeyManager instead of SSHKey class Signed-off-by: Aditya Wasan Signed-off-by: Harsh Shandilya * refactor(ssh): update package name Signed-off-by: Aditya Wasan * chore(ssh): fix detekt warnings Signed-off-by: Aditya Wasan * chore: fixes across the board --------- Signed-off-by: Aditya Wasan Signed-off-by: Harsh Shandilya Co-authored-by: Harsh Shandilya --- app/build.gradle.kts | 1 + .../injection/ssh/SSHKeyManagerModule.kt | 21 ++ .../ui/settings/RepositorySettings.kt | 16 +- .../ui/settings/SettingsActivity.kt | 8 +- .../ui/sshkeygen/ShowSshKeyFragment.kt | 9 +- .../ui/sshkeygen/SshKeyGenActivity.kt | 39 +-- .../ui/sshkeygen/SshKeyImportActivity.kt | 33 ++- .../util/git/operation/GitOperation.kt | 24 +- .../util/git/sshj/SshjSessionFactory.kt | 17 +- detekt-baselines/ssh.xml | 9 + settings.gradle.kts | 2 + ssh/build.gradle.kts | 27 ++ ssh/lint-baseline.xml | 4 + .../kotlin/app/passwordstore/ssh/SSHKey.kt | 5 + .../app/passwordstore/ssh/SSHKeyAlgorithm.kt | 7 + .../app/passwordstore/ssh/SSHKeyManager.kt | 273 ++++++++++++++++++ .../app/passwordstore/ssh/SSHKeyType.kt | 15 + .../ssh/generator/ECDSAKeyGenerator.kt | 53 ++++ .../ssh/generator/ED25519KeyGenerator.kt | 12 + .../ssh/generator/RSAKeyGenerator.kt | 54 ++++ .../ssh/generator/SSHKeyGenerator.kt | 11 + .../ssh/provider/KeystoreNativeKeyProvider.kt | 35 +++ .../KeystoreWrappedEd25519KeyProvider.kt | 55 ++++ .../app/passwordstore/ssh/utils/Constants.kt | 11 + .../app/passwordstore/ssh/utils/Exceptions.kt | 12 + .../app/passwordstore/ssh/utils/Extensions.kt | 49 ++++ .../passwordstore/ssh/utils/SSHKeyUtils.kt | 52 ++++ .../ssh/writer/ED25519KeyWriter.kt | 38 +++ .../ssh/writer/ImportedKeyWriter.kt | 12 + .../ssh/writer/KeystoreNativeKeyWriter.kt | 14 + .../passwordstore/ssh/writer/SSHKeyWriter.kt | 9 + ssh/src/main/res/values/strings.xml | 5 + 32 files changed, 877 insertions(+), 55 deletions(-) create mode 100644 app/src/main/java/app/passwordstore/injection/ssh/SSHKeyManagerModule.kt create mode 100644 detekt-baselines/ssh.xml create mode 100644 ssh/build.gradle.kts create mode 100644 ssh/lint-baseline.xml create mode 100644 ssh/src/main/kotlin/app/passwordstore/ssh/SSHKey.kt create mode 100644 ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyAlgorithm.kt create mode 100644 ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyManager.kt create mode 100644 ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyType.kt create mode 100644 ssh/src/main/kotlin/app/passwordstore/ssh/generator/ECDSAKeyGenerator.kt create mode 100644 ssh/src/main/kotlin/app/passwordstore/ssh/generator/ED25519KeyGenerator.kt create mode 100644 ssh/src/main/kotlin/app/passwordstore/ssh/generator/RSAKeyGenerator.kt create mode 100644 ssh/src/main/kotlin/app/passwordstore/ssh/generator/SSHKeyGenerator.kt create mode 100644 ssh/src/main/kotlin/app/passwordstore/ssh/provider/KeystoreNativeKeyProvider.kt create mode 100644 ssh/src/main/kotlin/app/passwordstore/ssh/provider/KeystoreWrappedEd25519KeyProvider.kt create mode 100644 ssh/src/main/kotlin/app/passwordstore/ssh/utils/Constants.kt create mode 100644 ssh/src/main/kotlin/app/passwordstore/ssh/utils/Exceptions.kt create mode 100644 ssh/src/main/kotlin/app/passwordstore/ssh/utils/Extensions.kt create mode 100644 ssh/src/main/kotlin/app/passwordstore/ssh/utils/SSHKeyUtils.kt create mode 100644 ssh/src/main/kotlin/app/passwordstore/ssh/writer/ED25519KeyWriter.kt create mode 100644 ssh/src/main/kotlin/app/passwordstore/ssh/writer/ImportedKeyWriter.kt create mode 100644 ssh/src/main/kotlin/app/passwordstore/ssh/writer/KeystoreNativeKeyWriter.kt create mode 100644 ssh/src/main/kotlin/app/passwordstore/ssh/writer/SSHKeyWriter.kt create mode 100644 ssh/src/main/res/values/strings.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5d005676..c6ca5871 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -50,6 +50,7 @@ dependencies { implementation(projects.formatCommon) implementation(projects.passgen.diceware) implementation(projects.passgen.random) + implementation(projects.ssh) implementation(projects.uiCompose) implementation(libs.androidx.activity.ktx) implementation(libs.androidx.activity.compose) diff --git a/app/src/main/java/app/passwordstore/injection/ssh/SSHKeyManagerModule.kt b/app/src/main/java/app/passwordstore/injection/ssh/SSHKeyManagerModule.kt new file mode 100644 index 00000000..c5abd487 --- /dev/null +++ b/app/src/main/java/app/passwordstore/injection/ssh/SSHKeyManagerModule.kt @@ -0,0 +1,21 @@ +package app.passwordstore.injection.ssh + +import android.content.Context +import app.passwordstore.ssh.SSHKeyManager +import dagger.Module +import dagger.Provides +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +object SSHKeyManagerModule { + + @Provides + @Reusable + fun provideSSHKeyManager(@ApplicationContext context: Context): SSHKeyManager { + return SSHKeyManager(context) + } +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt b/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt index 8bd61f41..c1ec8cbb 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt @@ -9,13 +9,14 @@ import android.content.Intent import android.content.SharedPreferences import android.content.pm.ShortcutManager import android.os.Build -import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.core.content.edit import androidx.core.content.getSystemService import androidx.fragment.app.FragmentActivity import app.passwordstore.R import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.injection.prefs.GitPreferences +import app.passwordstore.ssh.SSHKeyManager import app.passwordstore.ui.git.config.GitConfigActivity import app.passwordstore.ui.git.config.GitServerConfigActivity import app.passwordstore.ui.proxy.ProxySelectorActivity @@ -27,7 +28,6 @@ import app.passwordstore.util.extensions.launchActivity import app.passwordstore.util.extensions.sharedPrefs import app.passwordstore.util.extensions.snackbar import app.passwordstore.util.extensions.unsafeLazy -import app.passwordstore.util.git.sshj.SshKey import app.passwordstore.util.settings.GitSettings import app.passwordstore.util.settings.PreferenceKeys import com.github.michaelbull.result.onFailure @@ -43,11 +43,13 @@ import de.Maxr1998.modernpreferences.helpers.onClick import de.Maxr1998.modernpreferences.helpers.pref import de.Maxr1998.modernpreferences.helpers.switch -class RepositorySettings(private val activity: FragmentActivity) : SettingsProvider { - +class RepositorySettings( + private val activity: FragmentActivity, + private val sshKeyManager: SSHKeyManager, +) : SettingsProvider { private val generateSshKey = - activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - showSshKeyPref?.visible = SshKey.canShowSshPublicKey + activity.registerForActivityResult(StartActivityForResult()) { + showSshKeyPref?.visible = sshKeyManager.canShowPublicKey() } private val hiltEntryPoint by unsafeLazy { @@ -112,7 +114,7 @@ class RepositorySettings(private val activity: FragmentActivity) : SettingsProvi showSshKeyPref = pref(PreferenceKeys.SSH_SEE_KEY) { titleRes = R.string.pref_ssh_see_key_title - visible = PasswordRepository.isGitRepo() && SshKey.canShowSshPublicKey + visible = PasswordRepository.isGitRepo() && sshKeyManager.canShowPublicKey() onClick { ShowSshKeyFragment().show(activity.supportFragmentManager, "public_key") true diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt index 30e2b1b0..697d0156 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt @@ -11,19 +11,24 @@ import androidx.appcompat.app.AppCompatActivity import androidx.core.os.BundleCompat import app.passwordstore.R import app.passwordstore.databinding.ActivityPreferenceRecyclerviewBinding +import app.passwordstore.ssh.SSHKeyManager import app.passwordstore.util.extensions.viewBinding import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint import de.Maxr1998.modernpreferences.Preference import de.Maxr1998.modernpreferences.PreferencesAdapter import de.Maxr1998.modernpreferences.helpers.screen import de.Maxr1998.modernpreferences.helpers.subScreen +import javax.inject.Inject +@AndroidEntryPoint class SettingsActivity : AppCompatActivity() { + @Inject lateinit var sshKeyManager: SSHKeyManager + private lateinit var repositorySettings: RepositorySettings private val miscSettings = MiscSettings(this) private val autofillSettings = AutofillSettings(this) private val passwordSettings = PasswordSettings(this) - private val repositorySettings = RepositorySettings(this) private val generalSettings = GeneralSettings(this) private val pgpSettings = PGPSettings(this) @@ -35,6 +40,7 @@ class SettingsActivity : AppCompatActivity() { super.onCreate(savedInstanceState) setContentView(binding.root) Preference.Config.dialogBuilderFactory = { context -> MaterialAlertDialogBuilder(context) } + repositorySettings = RepositorySettings(this, sshKeyManager) val screen = screen(this) { subScreen { diff --git a/app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt b/app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt index a42d6aa1..4f52b540 100644 --- a/app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt +++ b/app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt @@ -9,14 +9,19 @@ import android.content.Intent import android.os.Bundle import androidx.fragment.app.DialogFragment import app.passwordstore.R -import app.passwordstore.util.git.sshj.SshKey +import app.passwordstore.ssh.SSHKeyManager import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +@AndroidEntryPoint class ShowSshKeyFragment : DialogFragment() { + @Inject lateinit var sshKeyManager: SSHKeyManager + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val activity = requireActivity() - val publicKey = SshKey.sshPublicKey + val publicKey = sshKeyManager.publicKey() return MaterialAlertDialogBuilder(requireActivity()).run { setMessage(getString(R.string.ssh_keygen_message, publicKey)) setTitle(R.string.your_public_key) diff --git a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt b/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt index 67528d24..eea2d659 100644 --- a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt @@ -17,11 +17,12 @@ import androidx.lifecycle.lifecycleScope import app.passwordstore.R import app.passwordstore.databinding.ActivitySshKeygenBinding import app.passwordstore.injection.prefs.GitPreferences +import app.passwordstore.ssh.SSHKeyAlgorithm +import app.passwordstore.ssh.SSHKeyManager import app.passwordstore.util.auth.BiometricAuthenticator import app.passwordstore.util.auth.BiometricAuthenticator.Result import app.passwordstore.util.extensions.keyguardManager import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.git.sshj.SshKey import com.github.michaelbull.result.fold import com.github.michaelbull.result.runCatching import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -33,24 +34,13 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -private enum class KeyGenType(val generateKey: suspend (requireAuthentication: Boolean) -> Unit) { - Rsa({ requireAuthentication -> - SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication) - }), - Ecdsa({ requireAuthentication -> - SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication) - }), - Ed25519({ requireAuthentication -> - SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication) - }), -} - @AndroidEntryPoint class SshKeyGenActivity : AppCompatActivity() { - private var keyGenType = KeyGenType.Ecdsa + private var sshKeyAlgorithm = SSHKeyAlgorithm.ECDSA private val binding by viewBinding(ActivitySshKeygenBinding::inflate) @GitPreferences @Inject lateinit var gitPrefs: SharedPreferences + @Inject lateinit var sshKeyManager: SSHKeyManager override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -58,7 +48,7 @@ class SshKeyGenActivity : AppCompatActivity() { supportActionBar?.setDisplayHomeAsUpEnabled(true) with(binding) { generate.setOnClickListener { - if (SshKey.exists) { + if (sshKeyManager.keyExists()) { MaterialAlertDialogBuilder(this@SshKeyGenActivity).run { setTitle(R.string.ssh_keygen_existing_title) setMessage(R.string.ssh_keygen_existing_message) @@ -79,18 +69,18 @@ class SshKeyGenActivity : AppCompatActivity() { keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa) keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked -> if (isChecked) { - keyGenType = + sshKeyAlgorithm = when (checkedId) { - R.id.key_type_ed25519 -> KeyGenType.Ed25519 - R.id.key_type_ecdsa -> KeyGenType.Ecdsa - R.id.key_type_rsa -> KeyGenType.Rsa + R.id.key_type_ed25519 -> SSHKeyAlgorithm.ED25519 + R.id.key_type_ecdsa -> SSHKeyAlgorithm.ECDSA + R.id.key_type_rsa -> SSHKeyAlgorithm.RSA else -> throw IllegalStateException("Impossible key type selection") } keyTypeExplanation.setText( - when (keyGenType) { - KeyGenType.Ed25519 -> R.string.ssh_keygen_explanation_ed25519 - KeyGenType.Ecdsa -> R.string.ssh_keygen_explanation_ecdsa - KeyGenType.Rsa -> R.string.ssh_keygen_explanation_rsa + when (sshKeyAlgorithm) { + SSHKeyAlgorithm.ED25519 -> R.string.ssh_keygen_explanation_ed25519 + SSHKeyAlgorithm.ECDSA -> R.string.ssh_keygen_explanation_ecdsa + SSHKeyAlgorithm.RSA -> R.string.ssh_keygen_explanation_rsa } ) } @@ -136,9 +126,10 @@ class SshKeyGenActivity : AppCompatActivity() { if (result !is Result.Success) throw UserNotAuthenticatedException(getString(R.string.biometric_auth_generic_failure)) } - keyGenType.generateKey(requireAuthentication) + sshKeyManager.generateKey(sshKeyAlgorithm, requireAuthentication) } } + // Check if we still need this gitPrefs.edit { remove("ssh_key_local_passphrase") } binding.generate.apply { text = getString(R.string.ssh_keygen_generate) diff --git a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt b/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt index 99b3bf3f..a5d276ae 100644 --- a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt @@ -10,14 +10,21 @@ import android.os.Bundle import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope import app.passwordstore.R -import app.passwordstore.util.git.sshj.SshKey +import app.passwordstore.ssh.SSHKeyManager import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.runCatching import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import kotlinx.coroutines.launch +@AndroidEntryPoint class SshKeyImportActivity : AppCompatActivity() { + @Inject lateinit var sshKeyManager: SSHKeyManager + private val sshKeyImportAction = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> if (uri == null) { @@ -25,15 +32,17 @@ class SshKeyImportActivity : AppCompatActivity() { return@registerForActivityResult } runCatching { - SshKey.import(uri) - Toast.makeText( - this, - resources.getString(R.string.ssh_key_success_dialog_title), - Toast.LENGTH_LONG - ) - .show() - setResult(RESULT_OK) - finish() + lifecycleScope.launch { + sshKeyManager.importKey(uri) + Toast.makeText( + this@SshKeyImportActivity, + resources.getString(R.string.ssh_key_success_dialog_title), + Toast.LENGTH_LONG + ) + .show() + setResult(RESULT_OK) + finish() + } } .onFailure { e -> MaterialAlertDialogBuilder(this) @@ -46,8 +55,8 @@ class SshKeyImportActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (SshKey.exists) { - MaterialAlertDialogBuilder(this).run { + if (sshKeyManager.keyExists()) { + MaterialAlertDialogBuilder(this@SshKeyImportActivity).run { setTitle(R.string.ssh_keygen_existing_title) setMessage(R.string.ssh_keygen_existing_message) setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> importSshKey() } diff --git a/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt index 92f17890..07ce7245 100644 --- a/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt +++ b/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt @@ -10,6 +10,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.FragmentActivity import app.passwordstore.R import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ssh.SSHKeyManager import app.passwordstore.ui.sshkeygen.SshKeyGenActivity import app.passwordstore.ui.sshkeygen.SshKeyImportActivity import app.passwordstore.util.auth.BiometricAuthenticator @@ -19,7 +20,6 @@ import app.passwordstore.util.auth.BiometricAuthenticator.Result.Retry import app.passwordstore.util.auth.BiometricAuthenticator.Result.Success import app.passwordstore.util.git.GitCommandExecutor import app.passwordstore.util.git.sshj.SshAuthMethod -import app.passwordstore.util.git.sshj.SshKey import app.passwordstore.util.git.sshj.SshjSessionFactory import app.passwordstore.util.settings.AuthMode import com.github.michaelbull.result.Err @@ -28,6 +28,10 @@ import com.github.michaelbull.result.Result import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.runCatching import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine import kotlinx.coroutines.Dispatchers @@ -62,6 +66,12 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { open val requiresAuth: Boolean = true private val hostKeyFile = callingActivity.filesDir.resolve(".host_key") private var sshSessionFactory: SshjSessionFactory? = null + private val hiltEntryPoint = + EntryPointAccessors.fromApplication( + callingActivity.applicationContext, + GitOperationEntryPoint::class.java + ) + private val sshKeyManager = hiltEntryPoint.sshKeyManager() protected val repository = PasswordRepository.repository!! protected val git = Git(repository) private val authActivity @@ -115,7 +125,7 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { authMethod: SshAuthMethod, credentialsProvider: CredentialsProvider? = null ) { - sshSessionFactory = SshjSessionFactory(authMethod, hostKeyFile) + sshSessionFactory = SshjSessionFactory(authMethod, hostKeyFile, sshKeyManager) commands.filterIsInstance>().forEach { command -> command.setTransportConfigCallback { transport: Transport -> (transport as? SshTransport)?.sshSessionFactory = sshSessionFactory @@ -163,8 +173,8 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { suspend fun executeAfterAuthentication(authMode: AuthMode): Result { when (authMode) { AuthMode.SshKey -> - if (SshKey.exists) { - if (SshKey.mustAuthenticate) { + if (sshKeyManager.keyExists()) { + if (sshKeyManager.needsAuthentication()) { val result = withContext(Dispatchers.Main) { suspendCoroutine { cont -> @@ -231,4 +241,10 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { /** Timeout in seconds before [TransportCommand] will abort a stalled IO operation. */ private const val CONNECT_TIMEOUT = 10 } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface GitOperationEntryPoint { + fun sshKeyManager(): SSHKeyManager + } } diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt index 58af8495..c6e648c5 100644 --- a/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt +++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt @@ -6,6 +6,7 @@ package app.passwordstore.util.git.sshj import android.util.Base64 import androidx.appcompat.app.AppCompatActivity +import app.passwordstore.ssh.SSHKeyManager import app.passwordstore.util.git.operation.CredentialFinder import app.passwordstore.util.settings.AuthMode import com.github.michaelbull.result.getOrElse @@ -65,8 +66,11 @@ abstract class InteractivePasswordFinder : PasswordFinder { final override fun shouldRetry(resource: Resource<*>?) = true } -class SshjSessionFactory(private val authMethod: SshAuthMethod, private val hostKeyFile: File) : - SshSessionFactory() { +class SshjSessionFactory( + private val authMethod: SshAuthMethod, + private val hostKeyFile: File, + private val sshKeyManager: SSHKeyManager, +) : SshSessionFactory() { private var currentSession: SshjSession? = null @@ -77,7 +81,7 @@ class SshjSessionFactory(private val authMethod: SshAuthMethod, private val host tms: Int ): RemoteSession { return currentSession - ?: SshjSession(uri, uri.user, authMethod, hostKeyFile).connect().also { + ?: SshjSession(uri, uri.user, authMethod, hostKeyFile, sshKeyManager).connect().also { logcat { "New SSH connection created" } currentSession = it } @@ -120,7 +124,8 @@ private class SshjSession( uri: URIish, private val username: String, private val authMethod: SshAuthMethod, - private val hostKeyFile: File + private val hostKeyFile: File, + private val sshKeyManager: SSHKeyManager, ) : RemoteSession { private lateinit var ssh: SSHClient @@ -154,7 +159,9 @@ private class SshjSession( } is SshAuthMethod.SshKey -> { val pubkeyAuth = - AuthPublickey(SshKey.provide(ssh, CredentialFinder(authMethod.activity, AuthMode.SshKey))) + AuthPublickey( + sshKeyManager.keyProvider(ssh, CredentialFinder(authMethod.activity, AuthMode.SshKey)) + ) ssh.auth(username, pubkeyAuth, passwordAuth) } } diff --git a/detekt-baselines/ssh.xml b/detekt-baselines/ssh.xml new file mode 100644 index 00000000..51033513 --- /dev/null +++ b/detekt-baselines/ssh.xml @@ -0,0 +1,9 @@ + + + + + ReturnCount:SSHKeyManager.kt$SSHKeyManager$public fun needsAuthentication(): Boolean + SwallowedException:SSHKeyManager.kt$SSHKeyManager$e: IllegalStateException + TooManyFunctions:SSHKeyManager.kt$SSHKeyManager + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 211653de..13c7212a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -186,3 +186,5 @@ include("passgen:random") include("sentry-stub") include("ui-compose") + +include("ssh") diff --git a/ssh/build.gradle.kts b/ssh/build.gradle.kts new file mode 100644 index 00000000..80e6f58c --- /dev/null +++ b/ssh/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright © The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +@file:Suppress("UnstableApiUsage") + +plugins { + id("com.github.android-password-store.android-library") + id("com.github.android-password-store.kotlin-android") + id("com.github.android-password-store.kotlin-library") +} + +android { + namespace = "app.passwordstore.ssh" + sourceSets { getByName("test") { resources.srcDir("src/main/res/raw") } } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.kotlin.coroutines.android) + implementation(libs.kotlin.coroutines.core) + implementation(libs.thirdparty.sshj) { exclude(group = "org.bouncycastle") } + implementation(libs.thirdparty.logcat) + implementation(libs.androidx.security) + implementation(libs.thirdparty.eddsa) + implementation(libs.thirdparty.kotlinResult) +} diff --git a/ssh/lint-baseline.xml b/ssh/lint-baseline.xml new file mode 100644 index 00000000..0722790e --- /dev/null +++ b/ssh/lint-baseline.xml @@ -0,0 +1,4 @@ + + + + diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/SSHKey.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/SSHKey.kt new file mode 100644 index 00000000..a9b7dba2 --- /dev/null +++ b/ssh/src/main/kotlin/app/passwordstore/ssh/SSHKey.kt @@ -0,0 +1,5 @@ +package app.passwordstore.ssh + +import java.io.File + +public data class SSHKey(val privateKey: File, val publicKey: File, val type: SSHKeyType) diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyAlgorithm.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyAlgorithm.kt new file mode 100644 index 00000000..1849c04d --- /dev/null +++ b/ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyAlgorithm.kt @@ -0,0 +1,7 @@ +package app.passwordstore.ssh + +public enum class SSHKeyAlgorithm { + RSA, + ECDSA, + ED25519, +} diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyManager.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyManager.kt new file mode 100644 index 00000000..ad435f98 --- /dev/null +++ b/ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyManager.kt @@ -0,0 +1,273 @@ +package app.passwordstore.ssh + +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.OpenableColumns +import android.security.keystore.KeyInfo +import androidx.core.content.edit +import app.passwordstore.ssh.generator.ECDSAKeyGenerator +import app.passwordstore.ssh.generator.ED25519KeyGenerator +import app.passwordstore.ssh.generator.RSAKeyGenerator +import app.passwordstore.ssh.provider.KeystoreNativeKeyProvider +import app.passwordstore.ssh.provider.KeystoreWrappedEd25519KeyProvider +import app.passwordstore.ssh.utils.Constants +import app.passwordstore.ssh.utils.Constants.ANDROIDX_SECURITY_KEYSET_PREF_NAME +import app.passwordstore.ssh.utils.Constants.KEYSTORE_ALIAS +import app.passwordstore.ssh.utils.Constants.PROVIDER_ANDROID_KEY_STORE +import app.passwordstore.ssh.utils.NullKeyException +import app.passwordstore.ssh.utils.SSHKeyNotFoundException +import app.passwordstore.ssh.utils.SSHKeyUtils +import app.passwordstore.ssh.utils.getEncryptedGitPrefs +import app.passwordstore.ssh.utils.sharedPrefs +import app.passwordstore.ssh.writer.ED25519KeyWriter +import app.passwordstore.ssh.writer.ImportedKeyWriter +import app.passwordstore.ssh.writer.KeystoreNativeKeyWriter +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.runCatching +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.security.KeyFactory +import java.security.KeyPair +import java.security.KeyStore +import java.security.PrivateKey +import javax.crypto.SecretKey +import javax.crypto.SecretKeyFactory +import logcat.asLog +import logcat.logcat +import net.schmizz.sshj.SSHClient +import net.schmizz.sshj.userauth.keyprovider.KeyProvider +import net.schmizz.sshj.userauth.password.PasswordFinder + +public class SSHKeyManager(private val applicationContext: Context) { + + private val androidKeystore: KeyStore by + lazy(LazyThreadSafetyMode.NONE) { + KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE).apply { load(null) } + } + private val isStrongBoxSupported by + lazy(LazyThreadSafetyMode.NONE) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + applicationContext.packageManager.hasSystemFeature( + PackageManager.FEATURE_STRONGBOX_KEYSTORE + ) + else false + } + + // Let's make this suspend so that we can use datastore's non-blocking apis + private fun keyType(): SSHKeyType { + return SSHKeyType.fromValue( + applicationContext.sharedPrefs.getString(Constants.GIT_REMOTE_KEY_TYPE, null) + ) + ?: throw NullKeyException() + } + + public fun keyExists(): Boolean { + return try { + keyType() + true + } catch (e: IllegalStateException) { + false + } + } + + public fun canShowPublicKey(): Boolean = + runCatching { + keyType() in + listOf( + SSHKeyType.LegacyGenerated, + SSHKeyType.KeystoreNative, + SSHKeyType.KeystoreWrappedEd25519 + ) + } + .getOrElse { false } + + public fun publicKey(): String? = + runCatching { createNewSSHKey(keyType = keyType()).publicKey.readText() } + .getOrElse { + return null + } + + public fun needsAuthentication(): Boolean { + return runCatching { + val keyType = keyType() + if (keyType == SSHKeyType.KeystoreNative || keyType == SSHKeyType.KeystoreWrappedEd25519) + return false + + when (val key = androidKeystore.getKey(KEYSTORE_ALIAS, null)) { + is PrivateKey -> { + val factory = KeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE) + factory.getKeySpec(key, KeyInfo::class.java).isUserAuthenticationRequired + } + is SecretKey -> { + val factory = SecretKeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE) + (factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo).isUserAuthenticationRequired + } + else -> throw SSHKeyNotFoundException() + } + } + .getOrElse { error -> + // It is fine to swallow the exception here since it will reappear when the key + // is used for SSH authentication and can then be shown in the UI. + logcat { error.asLog() } + false + } + } + + public suspend fun importKey(uri: Uri) { + // First check whether the content at uri is likely an SSH private key. + val fileSize = + applicationContext.contentResolver + .query(uri, arrayOf(OpenableColumns.SIZE), null, null, null) + ?.use { cursor -> + // Cursor returns only a single row. + cursor.moveToFirst() + cursor.getInt(0) + } + ?: throw IOException(applicationContext.getString(R.string.ssh_key_does_not_exist)) + // We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes. + require(fileSize in 1 until SSH_KEY_MAX_FILE_SIZE) { + applicationContext.getString(R.string.ssh_key_import_error_not_an_ssh_key_message) + } + val sshKeyInputStream = + applicationContext.contentResolver.openInputStream(uri) + ?: throw IOException(applicationContext.getString(R.string.ssh_key_does_not_exist)) + + importKey(sshKeyInputStream) + } + + private suspend fun importKey(sshKeyInputStream: InputStream) { + val lines = sshKeyInputStream.bufferedReader().readLines() + // The file must have more than 2 lines, and the first and last line must have private key + // markers. + check(SSHKeyUtils.isValid(lines)) { + applicationContext.getString(R.string.ssh_key_import_error_not_an_ssh_key_message) + } + // At this point, we are reasonably confident that we have actually been provided a private + // key and delete the old key. + deleteKey() + val sshKey = createNewSSHKey(keyType = SSHKeyType.Imported) + saveImportedKey(lines.joinToString("\n"), sshKey) + } + + public suspend fun generateKey(algorithm: SSHKeyAlgorithm, requiresAuthentication: Boolean) { + deleteKey() + val (sshKeyGenerator, sshKeyType) = + when (algorithm) { + SSHKeyAlgorithm.RSA -> Pair(RSAKeyGenerator(), SSHKeyType.KeystoreNative) + SSHKeyAlgorithm.ECDSA -> + Pair(ECDSAKeyGenerator(isStrongBoxSupported), SSHKeyType.KeystoreNative) + SSHKeyAlgorithm.ED25519 -> Pair(ED25519KeyGenerator(), SSHKeyType.KeystoreWrappedEd25519) + } + val keyPair = sshKeyGenerator.generateKey(requiresAuthentication) + val sshKeyFile = createNewSSHKey(keyType = sshKeyType) + saveGeneratedKey(keyPair, sshKeyFile, requiresAuthentication) + } + + private suspend fun saveGeneratedKey( + keyPair: KeyPair, + sshKey: SSHKey, + requiresAuthentication: Boolean + ) { + val sshKeyWriter = + when (sshKey.type) { + SSHKeyType.Imported -> + throw UnsupportedOperationException("KeyType imported is not supported with a KeyPair") + SSHKeyType.KeystoreNative -> KeystoreNativeKeyWriter() + SSHKeyType.KeystoreWrappedEd25519 -> + ED25519KeyWriter(applicationContext, requiresAuthentication) + SSHKeyType.LegacyGenerated -> + error("saveGeneratedKey should not be called with a legacy generated key") + } + + sshKeyWriter.writeKeyPair(keyPair, sshKey) + setSSHKeyType(sshKey.type) + } + + private suspend fun saveImportedKey(key: String, sshKey: SSHKey) { + val sshKeyWriter = + when (sshKey.type) { + SSHKeyType.Imported -> ImportedKeyWriter(key) + SSHKeyType.KeystoreNative -> + throw UnsupportedOperationException( + "KeyType KeystoreNative is not supported with a string key" + ) + SSHKeyType.KeystoreWrappedEd25519 -> + throw UnsupportedOperationException( + "KeyType KeystoreWrappedEd25519 is not supported with a string key" + ) + SSHKeyType.LegacyGenerated -> + error("saveImportedKey should not be called with a legacy generated key") + } + + sshKeyWriter.writeKeyPair(KeyPair(null, null), sshKey) + setSSHKeyType(SSHKeyType.Imported) + } + + private fun deleteKey() { + androidKeystore.deleteEntry(KEYSTORE_ALIAS) + // Remove Tink key set used by AndroidX's EncryptedFile. + applicationContext + .getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE) + .edit { clear() } + // If there's no keyType(), we'll just use SSHKeyType.Imported, since they key is going to be + // deleted, it does not really matter what the key type is. + // The other way to handle this is to return if the keyType() throws an exception. + val sshKey = + runCatching { createNewSSHKey(keyType = keyType()) } + .getOrElse { createNewSSHKey(keyType = SSHKeyType.Imported) } + if (sshKey.privateKey.isFile) { + sshKey.privateKey.delete() + } + if (sshKey.publicKey.isFile) { + sshKey.publicKey.delete() + } + + clearSSHKeyPreferences() + } + + public fun keyProvider(client: SSHClient, passphraseFinder: PasswordFinder): KeyProvider? { + val sshKeyFile = + runCatching { createNewSSHKey(keyType = keyType()) } + .getOrElse { + return null + } + + return when (sshKeyFile.type) { + SSHKeyType.LegacyGenerated, + SSHKeyType.Imported -> client.loadKeys(sshKeyFile.privateKey.absolutePath, passphraseFinder) + SSHKeyType.KeystoreNative -> KeystoreNativeKeyProvider(androidKeystore) + SSHKeyType.KeystoreWrappedEd25519 -> + KeystoreWrappedEd25519KeyProvider(applicationContext, sshKeyFile) + } + } + + private fun setSSHKeyType(sshKeyType: SSHKeyType) { + applicationContext.sharedPrefs.edit { + putString(Constants.GIT_REMOTE_KEY_TYPE, sshKeyType.value) + } + } + + private fun clearSSHKeyPreferences() { + applicationContext.getEncryptedGitPrefs().edit { remove(Constants.SSH_KEY_LOCAL_PASSPHRASE) } + applicationContext.sharedPrefs.edit { remove(Constants.GIT_REMOTE_KEY_TYPE) } + } + + private fun createNewSSHKey( + keyType: SSHKeyType, + privateKeyFileName: String = Constants.PRIVATE_SSH_KEY_FILE_NAME, + publicKeyFileName: String = Constants.PUBLIC_SSH_KEY_FILE_NAME + ): SSHKey { + val privateKeyFile = File(applicationContext.filesDir, privateKeyFileName) + val publicKeyFile = File(applicationContext.filesDir, publicKeyFileName) + + return SSHKey(privateKeyFile, publicKeyFile, keyType) + } + + private companion object { + + private const val SSH_KEY_MAX_FILE_SIZE = 100_000 + } +} diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyType.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyType.kt new file mode 100644 index 00000000..4a716ac8 --- /dev/null +++ b/ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyType.kt @@ -0,0 +1,15 @@ +package app.passwordstore.ssh + +public enum class SSHKeyType(internal val value: String) { + Imported("imported"), + KeystoreNative("keystore_native"), + KeystoreWrappedEd25519("keystore_wrapped_ed25519"), + // Behaves like `Imported`, but allows to view the public key. + LegacyGenerated("legacy_generated"), + ; + + public companion object { + + public fun fromValue(type: String?): SSHKeyType? = values().associateBy { it.value }[type] + } +} diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/generator/ECDSAKeyGenerator.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/generator/ECDSAKeyGenerator.kt new file mode 100644 index 00000000..b32d0933 --- /dev/null +++ b/ssh/src/main/kotlin/app/passwordstore/ssh/generator/ECDSAKeyGenerator.kt @@ -0,0 +1,53 @@ +package app.passwordstore.ssh.generator + +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import app.passwordstore.ssh.utils.Constants.KEYSTORE_ALIAS +import app.passwordstore.ssh.utils.Constants.PROVIDER_ANDROID_KEY_STORE +import java.security.KeyPair +import java.security.KeyPairGenerator + +public class ECDSAKeyGenerator(private val isStrongBoxSupported: Boolean) : SSHKeyGenerator { + + override suspend fun generateKey(requiresAuthentication: Boolean): KeyPair { + val algorithm = KeyProperties.KEY_ALGORITHM_EC + + val parameterSpec = + KeyGenParameterSpec.Builder(KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN).run { + setKeySize(ECDSA_KEY_SIZE) + setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1")) + setDigests(KeyProperties.DIGEST_SHA256) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + setIsStrongBoxBacked(isStrongBoxSupported) + } + if (requiresAuthentication) { + setUserAuthenticationRequired(true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + setUserAuthenticationParameters( + SSHKeyGenerator.USER_AUTHENTICATION_TIMEOUT, + KeyProperties.AUTH_DEVICE_CREDENTIAL + ) + } else { + @Suppress("DEPRECATION") + setUserAuthenticationValidityDurationSeconds( + SSHKeyGenerator.USER_AUTHENTICATION_TIMEOUT + ) + } + } + build() + } + + val keyPair = + KeyPairGenerator.getInstance(algorithm, PROVIDER_ANDROID_KEY_STORE).run { + initialize(parameterSpec) + generateKeyPair() + } + + return keyPair + } + + private companion object { + private const val ECDSA_KEY_SIZE = 256 + } +} diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/generator/ED25519KeyGenerator.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/generator/ED25519KeyGenerator.kt new file mode 100644 index 00000000..418e9ad9 --- /dev/null +++ b/ssh/src/main/kotlin/app/passwordstore/ssh/generator/ED25519KeyGenerator.kt @@ -0,0 +1,12 @@ +package app.passwordstore.ssh.generator + +import java.security.KeyPair +import net.i2p.crypto.eddsa.KeyPairGenerator + +public class ED25519KeyGenerator : SSHKeyGenerator { + + override suspend fun generateKey(requiresAuthentication: Boolean): KeyPair { + // Generate the ed25519 key pair and encrypt the private key. + return KeyPairGenerator().generateKeyPair() + } +} diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/generator/RSAKeyGenerator.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/generator/RSAKeyGenerator.kt new file mode 100644 index 00000000..54d9ae03 --- /dev/null +++ b/ssh/src/main/kotlin/app/passwordstore/ssh/generator/RSAKeyGenerator.kt @@ -0,0 +1,54 @@ +package app.passwordstore.ssh.generator + +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import app.passwordstore.ssh.utils.Constants.KEYSTORE_ALIAS +import app.passwordstore.ssh.utils.Constants.PROVIDER_ANDROID_KEY_STORE +import java.security.KeyPair +import java.security.KeyPairGenerator + +public class RSAKeyGenerator : SSHKeyGenerator { + + override suspend fun generateKey(requiresAuthentication: Boolean): KeyPair { + val algorithm = KeyProperties.KEY_ALGORITHM_RSA + // Generate Keystore-backed private key. + val parameterSpec = + KeyGenParameterSpec.Builder(KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN).run { + setKeySize(RSA_KEY_SIZE) + setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1) + setDigests( + KeyProperties.DIGEST_SHA1, + KeyProperties.DIGEST_SHA256, + KeyProperties.DIGEST_SHA512, + ) + if (requiresAuthentication) { + setUserAuthenticationRequired(true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + setUserAuthenticationParameters( + SSHKeyGenerator.USER_AUTHENTICATION_TIMEOUT, + KeyProperties.AUTH_DEVICE_CREDENTIAL + ) + } else { + @Suppress("DEPRECATION") + setUserAuthenticationValidityDurationSeconds( + SSHKeyGenerator.USER_AUTHENTICATION_TIMEOUT + ) + } + } + build() + } + + val keyPair = + KeyPairGenerator.getInstance(algorithm, PROVIDER_ANDROID_KEY_STORE).run { + initialize(parameterSpec) + generateKeyPair() + } + + return keyPair + } + + private companion object { + private const val RSA_KEY_SIZE = 3072 + } +} diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/generator/SSHKeyGenerator.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/generator/SSHKeyGenerator.kt new file mode 100644 index 00000000..09a64481 --- /dev/null +++ b/ssh/src/main/kotlin/app/passwordstore/ssh/generator/SSHKeyGenerator.kt @@ -0,0 +1,11 @@ +package app.passwordstore.ssh.generator + +import java.security.KeyPair + +public interface SSHKeyGenerator { + public suspend fun generateKey(requiresAuthentication: Boolean): KeyPair + + public companion object { + public const val USER_AUTHENTICATION_TIMEOUT: Int = 30 + } +} diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/provider/KeystoreNativeKeyProvider.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/provider/KeystoreNativeKeyProvider.kt new file mode 100644 index 00000000..68505b8b --- /dev/null +++ b/ssh/src/main/kotlin/app/passwordstore/ssh/provider/KeystoreNativeKeyProvider.kt @@ -0,0 +1,35 @@ +package app.passwordstore.ssh.provider + +import app.passwordstore.ssh.utils.Constants.KEYSTORE_ALIAS +import app.passwordstore.ssh.utils.sshPrivateKey +import app.passwordstore.ssh.utils.sshPublicKey +import java.io.IOException +import java.security.KeyStore +import java.security.PrivateKey +import java.security.PublicKey +import logcat.asLog +import logcat.logcat +import net.schmizz.sshj.common.KeyType +import net.schmizz.sshj.userauth.keyprovider.KeyProvider + +internal class KeystoreNativeKeyProvider(private val androidKeystore: KeyStore) : KeyProvider { + + override fun getPublic(): PublicKey = + runCatching { androidKeystore.sshPublicKey!! } + .getOrElse { error -> + logcat { error.asLog() } + throw IOException("Failed to get public key '$KEYSTORE_ALIAS' from Android Keystore", error) + } + + override fun getPrivate(): PrivateKey = + runCatching { androidKeystore.sshPrivateKey!! } + .getOrElse { error -> + logcat { error.asLog() } + throw IOException( + "Failed to access private key '$KEYSTORE_ALIAS' from Android Keystore", + error + ) + } + + override fun getType(): KeyType = KeyType.fromKey(public) +} diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/provider/KeystoreWrappedEd25519KeyProvider.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/provider/KeystoreWrappedEd25519KeyProvider.kt new file mode 100644 index 00000000..31a57998 --- /dev/null +++ b/ssh/src/main/kotlin/app/passwordstore/ssh/provider/KeystoreWrappedEd25519KeyProvider.kt @@ -0,0 +1,55 @@ +package app.passwordstore.ssh.provider + +import android.content.Context +import app.passwordstore.ssh.SSHKey +import app.passwordstore.ssh.utils.SSHKeyUtils.getOrCreateWrappedPrivateKeyFile +import app.passwordstore.ssh.utils.parseStringPublicKey +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.runCatching +import java.io.IOException +import java.security.PrivateKey +import java.security.PublicKey +import kotlinx.coroutines.runBlocking +import logcat.asLog +import logcat.logcat +import net.i2p.crypto.eddsa.EdDSAPrivateKey +import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable +import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec +import net.schmizz.sshj.common.KeyType +import net.schmizz.sshj.userauth.keyprovider.KeyProvider + +internal class KeystoreWrappedEd25519KeyProvider( + private val context: Context, + private val sshKeyFile: SSHKey +) : KeyProvider { + + override fun getPublic(): PublicKey = + runCatching { sshKeyFile.publicKey.readText().parseStringPublicKey()!! } + .getOrElse { error -> + logcat { error.asLog() } + throw IOException("Failed to get the public key for wrapped ed25519 key", error) + } + + override fun getPrivate(): PrivateKey = + runCatching { + // The current MasterKey API does not allow getting a reference to an existing + // one + // without specifying the KeySpec for a new one. However, the value for passed + // here + // for `requireAuthentication` is not used as the key already exists at this + // point. + val encryptedPrivateKeyFile = runBlocking { + getOrCreateWrappedPrivateKeyFile(context, false, sshKeyFile.privateKey) + } + val rawPrivateKey = encryptedPrivateKeyFile.openFileInput().use { it.readBytes() } + EdDSAPrivateKey( + EdDSAPrivateKeySpec(rawPrivateKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC) + ) + } + .getOrElse { error -> + logcat { error.asLog() } + throw IOException("Failed to unwrap wrapped ed25519 key", error) + } + + override fun getType(): KeyType = KeyType.fromKey(public) +} diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/utils/Constants.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/utils/Constants.kt new file mode 100644 index 00000000..ccc33094 --- /dev/null +++ b/ssh/src/main/kotlin/app/passwordstore/ssh/utils/Constants.kt @@ -0,0 +1,11 @@ +package app.passwordstore.ssh.utils + +internal object Constants { + const val ANDROIDX_SECURITY_KEYSET_PREF_NAME = "androidx_sshkey_keyset_prefs" + const val GIT_REMOTE_KEY_TYPE = "git_remote_key_type" + const val KEYSTORE_ALIAS = "sshkey" + const val PRIVATE_SSH_KEY_FILE_NAME = ".ssh_key" + const val PROVIDER_ANDROID_KEY_STORE = "AndroidKeyStore" + const val PUBLIC_SSH_KEY_FILE_NAME = ".ssh_key.pub" + const val SSH_KEY_LOCAL_PASSPHRASE = "ssh_key_local_passphrase" +} diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/utils/Exceptions.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/utils/Exceptions.kt new file mode 100644 index 00000000..db921ab6 --- /dev/null +++ b/ssh/src/main/kotlin/app/passwordstore/ssh/utils/Exceptions.kt @@ -0,0 +1,12 @@ +package app.passwordstore.ssh.utils + +public sealed class SSHException(message: String? = null, cause: Throwable? = null) : + Exception(message, cause) + +public class NullKeyException(message: String? = "keyType was null", cause: Throwable? = null) : + SSHException(message, cause) + +public class SSHKeyNotFoundException( + message: String? = "SSH key does not exist in Keystore", + cause: Throwable? = null +) : SSHException(message, cause) diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/utils/Extensions.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/utils/Extensions.kt new file mode 100644 index 00000000..e1c337c1 --- /dev/null +++ b/ssh/src/main/kotlin/app/passwordstore/ssh/utils/Extensions.kt @@ -0,0 +1,49 @@ +package app.passwordstore.ssh.utils + +import android.content.Context +import android.content.SharedPreferences +import android.util.Base64 +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import app.passwordstore.ssh.utils.Constants.KEYSTORE_ALIAS +import java.security.KeyStore +import java.security.PrivateKey +import java.security.PublicKey +import net.schmizz.sshj.common.Buffer +import net.schmizz.sshj.common.KeyType + +/** Get the default [SharedPreferences] instance */ +internal val Context.sharedPrefs: SharedPreferences + get() = getSharedPreferences("app.passwordstore_preferences", 0) +internal val KeyStore.sshPrivateKey + get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey +internal val KeyStore.sshPublicKey + get() = getCertificate(KEYSTORE_ALIAS)?.publicKey + +internal fun String.parseStringPublicKey(): PublicKey? { + val sshKeyParts = this.split("""\s+""".toRegex()) + if (sshKeyParts.size < 2) return null + return Buffer.PlainBuffer(Base64.decode(sshKeyParts[1], Base64.NO_WRAP)).readPublicKey() +} + +internal fun PublicKey.createStringPublicKey(): String { + val rawPublicKey = Buffer.PlainBuffer().putPublicKey(this).compactData + val keyType = KeyType.fromKey(this) + return "$keyType ${Base64.encodeToString(rawPublicKey, Base64.NO_WRAP)}" +} + +/** Wrapper for [getEncryptedPrefs] to avoid open-coding the file name at each call site */ +internal fun Context.getEncryptedGitPrefs() = getEncryptedPrefs("git_operation") + +/** Get an instance of [EncryptedSharedPreferences] with the given [fileName] */ +private fun Context.getEncryptedPrefs(fileName: String): SharedPreferences { + val masterKeyAlias = + MasterKey.Builder(applicationContext).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() + return EncryptedSharedPreferences.create( + applicationContext, + fileName, + masterKeyAlias, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) +} diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/utils/SSHKeyUtils.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/utils/SSHKeyUtils.kt new file mode 100644 index 00000000..86ce478e --- /dev/null +++ b/ssh/src/main/kotlin/app/passwordstore/ssh/utils/SSHKeyUtils.kt @@ -0,0 +1,52 @@ +package app.passwordstore.ssh.utils + +import android.content.Context +import androidx.security.crypto.EncryptedFile +import androidx.security.crypto.MasterKey +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal object SSHKeyUtils { + + private const val USER_AUTHENTICATION_VALIDITY_DURATION = 15 + + fun isValid(lines: List): Boolean { + return lines.size > 2 && + Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) && + Regex("END .* PRIVATE KEY").containsMatchIn(lines.last()) + } + + @Suppress("BlockingMethodInNonBlockingContext") + suspend fun getOrCreateWrappedPrivateKeyFile( + context: Context, + requiresAuthentication: Boolean, + privateKeyFile: File + ) = + withContext(Dispatchers.IO) { + EncryptedFile.Builder( + context, + privateKeyFile, + getOrCreateWrappingMasterKey(context, requiresAuthentication), + EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB + ) + .run { + setKeysetPrefName(Constants.ANDROIDX_SECURITY_KEYSET_PREF_NAME) + build() + } + } + + @Suppress("BlockingMethodInNonBlockingContext") + private suspend fun getOrCreateWrappingMasterKey( + context: Context, + requireAuthentication: Boolean + ) = + withContext(Dispatchers.IO) { + MasterKey.Builder(context, Constants.KEYSTORE_ALIAS).run { + setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + setRequestStrongBoxBacked(true) + setUserAuthenticationRequired(requireAuthentication, USER_AUTHENTICATION_VALIDITY_DURATION) + build() + } + } +} diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/writer/ED25519KeyWriter.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/writer/ED25519KeyWriter.kt new file mode 100644 index 00000000..ac2c983c --- /dev/null +++ b/ssh/src/main/kotlin/app/passwordstore/ssh/writer/ED25519KeyWriter.kt @@ -0,0 +1,38 @@ +package app.passwordstore.ssh.writer + +import android.content.Context +import app.passwordstore.ssh.SSHKey +import app.passwordstore.ssh.utils.SSHKeyUtils.getOrCreateWrappedPrivateKeyFile +import app.passwordstore.ssh.utils.createStringPublicKey +import java.io.File +import java.security.KeyPair +import java.security.PrivateKey +import java.security.PublicKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.i2p.crypto.eddsa.EdDSAPrivateKey + +public class ED25519KeyWriter( + private val context: Context, + private val requiresAuthentication: Boolean, +) : SSHKeyWriter { + + override suspend fun writeKeyPair(keyPair: KeyPair, sshKeyFile: SSHKey) { + writePrivateKey(keyPair.private, sshKeyFile.privateKey) + writePublicKey(keyPair.public, sshKeyFile.publicKey) + } + + private suspend fun writePrivateKey(privateKey: PrivateKey, privateKeyFile: File) { + withContext(Dispatchers.IO) { + val encryptedPrivateKeyFile = + getOrCreateWrappedPrivateKeyFile(context, requiresAuthentication, privateKeyFile) + encryptedPrivateKeyFile.openFileOutput().use { os -> + os.write((privateKey as EdDSAPrivateKey).seed) + } + } + } + + private suspend fun writePublicKey(publicKey: PublicKey, publicKeyFile: File) { + publicKeyFile.writeText(publicKey.createStringPublicKey()) + } +} diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/writer/ImportedKeyWriter.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/writer/ImportedKeyWriter.kt new file mode 100644 index 00000000..809a3d60 --- /dev/null +++ b/ssh/src/main/kotlin/app/passwordstore/ssh/writer/ImportedKeyWriter.kt @@ -0,0 +1,12 @@ +package app.passwordstore.ssh.writer + +import app.passwordstore.ssh.SSHKey +import java.security.KeyPair + +public class ImportedKeyWriter(private val privateKey: String) : SSHKeyWriter { + + override suspend fun writeKeyPair(keyPair: KeyPair, sshKeyFile: SSHKey) { + // Write the string key instead of the key from the key pair + sshKeyFile.privateKey.writeText(privateKey) + } +} diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/writer/KeystoreNativeKeyWriter.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/writer/KeystoreNativeKeyWriter.kt new file mode 100644 index 00000000..24cc02b9 --- /dev/null +++ b/ssh/src/main/kotlin/app/passwordstore/ssh/writer/KeystoreNativeKeyWriter.kt @@ -0,0 +1,14 @@ +package app.passwordstore.ssh.writer + +import app.passwordstore.ssh.SSHKey +import app.passwordstore.ssh.utils.createStringPublicKey +import java.security.KeyPair + +public class KeystoreNativeKeyWriter : SSHKeyWriter { + + override suspend fun writeKeyPair(keyPair: KeyPair, sshKeyFile: SSHKey) { + // Android Keystore manages the private key for us + // Write public key in SSH format to .ssh_key.pub. + sshKeyFile.publicKey.writeText(keyPair.public.createStringPublicKey()) + } +} diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/writer/SSHKeyWriter.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/writer/SSHKeyWriter.kt new file mode 100644 index 00000000..c5866086 --- /dev/null +++ b/ssh/src/main/kotlin/app/passwordstore/ssh/writer/SSHKeyWriter.kt @@ -0,0 +1,9 @@ +package app.passwordstore.ssh.writer + +import app.passwordstore.ssh.SSHKey +import java.security.KeyPair + +public interface SSHKeyWriter { + + public suspend fun writeKeyPair(keyPair: KeyPair, sshKeyFile: SSHKey) +} diff --git a/ssh/src/main/res/values/strings.xml b/ssh/src/main/res/values/strings.xml new file mode 100644 index 00000000..f35fe0ac --- /dev/null +++ b/ssh/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + Unable to open the ssh private key, please check that the file exists + Selected file does not appear to be an SSH private key. +