summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAditya Wasan <adityawasan55@gmail.com>2023-04-02 14:04:33 -0400
committerGitHub <noreply@github.com>2023-04-02 18:04:33 +0000
commit97b3577a463966e93d24649ff348fc4bb6825e50 (patch)
tree8662fa55b2ab85fba58b3d8f7e3fe036bc0475f6
parent577d6ab55a331fe842fed25fdf96b22bac345d90 (diff)
Refactor SSHKey into a separate module (#2450)
* refactor(ssh): add `ssh` module Signed-off-by: Aditya Wasan <adityawasan55@gmail.com> * refactor(ssh): add `SSHKey` data class Signed-off-by: Aditya Wasan <adityawasan55@gmail.com> * refactor(ssh): add `SSHKeyType` enum Signed-off-by: Aditya Wasan <adityawasan55@gmail.com> * refactor(ssh): add `SSHKeyAlgorithm` class Signed-off-by: Aditya Wasan <adityawasan55@gmail.com> * refactor(ssh): add class to generate `RSA` key Signed-off-by: Aditya Wasan <adityawasan55@gmail.com> * chore(ssh): add required dependencies Signed-off-by: Aditya Wasan <adityawasan55@gmail.com> * refactor(ssh): add `ECDSAKeyGenerator` and remove constants Signed-off-by: Aditya Wasan <adityawasan55@gmail.com> * refactor(ssh): add utilities Signed-off-by: Aditya Wasan <adityawasan55@gmail.com> * feat(ssh): add `SSHKeyWriter` Signed-off-by: Aditya Wasan <adityawasan55@gmail.com> * refactor(ssh): make ssh key generators suspending Signed-off-by: Aditya Wasan <adityawasan55@gmail.com> * fix(ssh): fix explicit API violations * feat: complete `ED25519KeyWriter` implementation Signed-off-by: Aditya Wasan <adityawasan55@gmail.com> * factor(ssh/writer): update writer interface Signed-off-by: Aditya Wasan <adityawasan55@gmail.com> * feat(ssh/provider): add providers for different key types Signed-off-by: Aditya Wasan <adityawasan55@gmail.com> * feat(ssh): add SSHKeyManager for common key functionality Signed-off-by: Aditya Wasan <adityawasan55@gmail.com> * feat(ssh): add remaining methods to reach feature parity with old SSH implementation Signed-off-by: Aditya Wasan <adityawasan55@gmail.com> * wip(app): start using SSHKeyManager instead of SSHKey class Signed-off-by: Aditya Wasan <adityawasan55@gmail.com> Signed-off-by: Harsh Shandilya <me@msfjarvis.dev> * refactor(ssh): update package name Signed-off-by: Aditya Wasan <adityawasan55@gmail.com> * chore(ssh): fix detekt warnings Signed-off-by: Aditya Wasan <adityawasan55@gmail.com> * chore: fixes across the board --------- Signed-off-by: Aditya Wasan <adityawasan55@gmail.com> Signed-off-by: Harsh Shandilya <me@msfjarvis.dev> Co-authored-by: Harsh Shandilya <me@msfjarvis.dev>
-rw-r--r--app/build.gradle.kts1
-rw-r--r--app/src/main/java/app/passwordstore/injection/ssh/SSHKeyManagerModule.kt21
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt16
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt8
-rw-r--r--app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt9
-rw-r--r--app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt39
-rw-r--r--app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt33
-rw-r--r--app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt24
-rw-r--r--app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt17
-rw-r--r--detekt-baselines/ssh.xml9
-rw-r--r--settings.gradle.kts2
-rw-r--r--ssh/build.gradle.kts27
-rw-r--r--ssh/lint-baseline.xml4
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/SSHKey.kt5
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyAlgorithm.kt7
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyManager.kt273
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyType.kt15
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/generator/ECDSAKeyGenerator.kt53
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/generator/ED25519KeyGenerator.kt12
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/generator/RSAKeyGenerator.kt54
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/generator/SSHKeyGenerator.kt11
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/provider/KeystoreNativeKeyProvider.kt35
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/provider/KeystoreWrappedEd25519KeyProvider.kt55
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/utils/Constants.kt11
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/utils/Exceptions.kt12
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/utils/Extensions.kt49
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/utils/SSHKeyUtils.kt52
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/writer/ED25519KeyWriter.kt38
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/writer/ImportedKeyWriter.kt12
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/writer/KeystoreNativeKeyWriter.kt14
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/writer/SSHKeyWriter.kt9
-rw-r--r--ssh/src/main/res/values/strings.xml5
32 files changed, 877 insertions, 55 deletions
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<TransportCommand<*, *>>().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<Unit, Throwable> {
when (authMode) {
AuthMode.SshKey ->
- if (SshKey.exists) {
- if (SshKey.mustAuthenticate) {
+ if (sshKeyManager.keyExists()) {
+ if (sshKeyManager.needsAuthentication()) {
val result =
withContext(Dispatchers.Main) {
suspendCoroutine<BiometricAuthenticator.Result> { 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 @@
+<?xml version="1.0" ?>
+<SmellBaseline>
+ <ManuallySuppressedIssues></ManuallySuppressedIssues>
+ <CurrentIssues>
+ <ID>ReturnCount:SSHKeyManager.kt$SSHKeyManager$public fun needsAuthentication(): Boolean</ID>
+ <ID>SwallowedException:SSHKeyManager.kt$SSHKeyManager$e: IllegalStateException</ID>
+ <ID>TooManyFunctions:SSHKeyManager.kt$SSHKeyManager</ID>
+ </CurrentIssues>
+</SmellBaseline>
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 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<issues format="6" by="lint 7.4.2" type="baseline" client="gradle" dependencies="false" name="AGP (7.4.2)" variant="all" version="7.4.2">
+
+</issues>
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<String>): 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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="ssh_key_does_not_exist">Unable to open the ssh private key, please check that the file exists</string>
+ <string name="ssh_key_import_error_not_an_ssh_key_message">Selected file does not appear to be an SSH private key.</string>
+</resources>