mirror of
https://github.com/android-password-store/Android-Password-Store
synced 2025-08-31 06:15:48 +00:00
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>
This commit is contained in:
@@ -50,6 +50,7 @@ dependencies {
|
|||||||
implementation(projects.formatCommon)
|
implementation(projects.formatCommon)
|
||||||
implementation(projects.passgen.diceware)
|
implementation(projects.passgen.diceware)
|
||||||
implementation(projects.passgen.random)
|
implementation(projects.passgen.random)
|
||||||
|
implementation(projects.ssh)
|
||||||
implementation(projects.uiCompose)
|
implementation(projects.uiCompose)
|
||||||
implementation(libs.androidx.activity.ktx)
|
implementation(libs.androidx.activity.ktx)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
@@ -9,13 +9,14 @@ import android.content.Intent
|
|||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.content.pm.ShortcutManager
|
import android.content.pm.ShortcutManager
|
||||||
import android.os.Build
|
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.edit
|
||||||
import androidx.core.content.getSystemService
|
import androidx.core.content.getSystemService
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import app.passwordstore.R
|
import app.passwordstore.R
|
||||||
import app.passwordstore.data.repo.PasswordRepository
|
import app.passwordstore.data.repo.PasswordRepository
|
||||||
import app.passwordstore.injection.prefs.GitPreferences
|
import app.passwordstore.injection.prefs.GitPreferences
|
||||||
|
import app.passwordstore.ssh.SSHKeyManager
|
||||||
import app.passwordstore.ui.git.config.GitConfigActivity
|
import app.passwordstore.ui.git.config.GitConfigActivity
|
||||||
import app.passwordstore.ui.git.config.GitServerConfigActivity
|
import app.passwordstore.ui.git.config.GitServerConfigActivity
|
||||||
import app.passwordstore.ui.proxy.ProxySelectorActivity
|
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.sharedPrefs
|
||||||
import app.passwordstore.util.extensions.snackbar
|
import app.passwordstore.util.extensions.snackbar
|
||||||
import app.passwordstore.util.extensions.unsafeLazy
|
import app.passwordstore.util.extensions.unsafeLazy
|
||||||
import app.passwordstore.util.git.sshj.SshKey
|
|
||||||
import app.passwordstore.util.settings.GitSettings
|
import app.passwordstore.util.settings.GitSettings
|
||||||
import app.passwordstore.util.settings.PreferenceKeys
|
import app.passwordstore.util.settings.PreferenceKeys
|
||||||
import com.github.michaelbull.result.onFailure
|
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.pref
|
||||||
import de.Maxr1998.modernpreferences.helpers.switch
|
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 =
|
private val generateSshKey =
|
||||||
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
activity.registerForActivityResult(StartActivityForResult()) {
|
||||||
showSshKeyPref?.visible = SshKey.canShowSshPublicKey
|
showSshKeyPref?.visible = sshKeyManager.canShowPublicKey()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val hiltEntryPoint by unsafeLazy {
|
private val hiltEntryPoint by unsafeLazy {
|
||||||
@@ -112,7 +114,7 @@ class RepositorySettings(private val activity: FragmentActivity) : SettingsProvi
|
|||||||
showSshKeyPref =
|
showSshKeyPref =
|
||||||
pref(PreferenceKeys.SSH_SEE_KEY) {
|
pref(PreferenceKeys.SSH_SEE_KEY) {
|
||||||
titleRes = R.string.pref_ssh_see_key_title
|
titleRes = R.string.pref_ssh_see_key_title
|
||||||
visible = PasswordRepository.isGitRepo() && SshKey.canShowSshPublicKey
|
visible = PasswordRepository.isGitRepo() && sshKeyManager.canShowPublicKey()
|
||||||
onClick {
|
onClick {
|
||||||
ShowSshKeyFragment().show(activity.supportFragmentManager, "public_key")
|
ShowSshKeyFragment().show(activity.supportFragmentManager, "public_key")
|
||||||
true
|
true
|
||||||
|
@@ -11,19 +11,24 @@ import androidx.appcompat.app.AppCompatActivity
|
|||||||
import androidx.core.os.BundleCompat
|
import androidx.core.os.BundleCompat
|
||||||
import app.passwordstore.R
|
import app.passwordstore.R
|
||||||
import app.passwordstore.databinding.ActivityPreferenceRecyclerviewBinding
|
import app.passwordstore.databinding.ActivityPreferenceRecyclerviewBinding
|
||||||
|
import app.passwordstore.ssh.SSHKeyManager
|
||||||
import app.passwordstore.util.extensions.viewBinding
|
import app.passwordstore.util.extensions.viewBinding
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.Maxr1998.modernpreferences.Preference
|
import de.Maxr1998.modernpreferences.Preference
|
||||||
import de.Maxr1998.modernpreferences.PreferencesAdapter
|
import de.Maxr1998.modernpreferences.PreferencesAdapter
|
||||||
import de.Maxr1998.modernpreferences.helpers.screen
|
import de.Maxr1998.modernpreferences.helpers.screen
|
||||||
import de.Maxr1998.modernpreferences.helpers.subScreen
|
import de.Maxr1998.modernpreferences.helpers.subScreen
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
class SettingsActivity : AppCompatActivity() {
|
class SettingsActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
@Inject lateinit var sshKeyManager: SSHKeyManager
|
||||||
|
private lateinit var repositorySettings: RepositorySettings
|
||||||
private val miscSettings = MiscSettings(this)
|
private val miscSettings = MiscSettings(this)
|
||||||
private val autofillSettings = AutofillSettings(this)
|
private val autofillSettings = AutofillSettings(this)
|
||||||
private val passwordSettings = PasswordSettings(this)
|
private val passwordSettings = PasswordSettings(this)
|
||||||
private val repositorySettings = RepositorySettings(this)
|
|
||||||
private val generalSettings = GeneralSettings(this)
|
private val generalSettings = GeneralSettings(this)
|
||||||
private val pgpSettings = PGPSettings(this)
|
private val pgpSettings = PGPSettings(this)
|
||||||
|
|
||||||
@@ -35,6 +40,7 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
Preference.Config.dialogBuilderFactory = { context -> MaterialAlertDialogBuilder(context) }
|
Preference.Config.dialogBuilderFactory = { context -> MaterialAlertDialogBuilder(context) }
|
||||||
|
repositorySettings = RepositorySettings(this, sshKeyManager)
|
||||||
val screen =
|
val screen =
|
||||||
screen(this) {
|
screen(this) {
|
||||||
subScreen {
|
subScreen {
|
||||||
|
@@ -9,14 +9,19 @@ import android.content.Intent
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.fragment.app.DialogFragment
|
import androidx.fragment.app.DialogFragment
|
||||||
import app.passwordstore.R
|
import app.passwordstore.R
|
||||||
import app.passwordstore.util.git.sshj.SshKey
|
import app.passwordstore.ssh.SSHKeyManager
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
class ShowSshKeyFragment : DialogFragment() {
|
class ShowSshKeyFragment : DialogFragment() {
|
||||||
|
|
||||||
|
@Inject lateinit var sshKeyManager: SSHKeyManager
|
||||||
|
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
val activity = requireActivity()
|
val activity = requireActivity()
|
||||||
val publicKey = SshKey.sshPublicKey
|
val publicKey = sshKeyManager.publicKey()
|
||||||
return MaterialAlertDialogBuilder(requireActivity()).run {
|
return MaterialAlertDialogBuilder(requireActivity()).run {
|
||||||
setMessage(getString(R.string.ssh_keygen_message, publicKey))
|
setMessage(getString(R.string.ssh_keygen_message, publicKey))
|
||||||
setTitle(R.string.your_public_key)
|
setTitle(R.string.your_public_key)
|
||||||
|
@@ -17,11 +17,12 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import app.passwordstore.R
|
import app.passwordstore.R
|
||||||
import app.passwordstore.databinding.ActivitySshKeygenBinding
|
import app.passwordstore.databinding.ActivitySshKeygenBinding
|
||||||
import app.passwordstore.injection.prefs.GitPreferences
|
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
|
||||||
import app.passwordstore.util.auth.BiometricAuthenticator.Result
|
import app.passwordstore.util.auth.BiometricAuthenticator.Result
|
||||||
import app.passwordstore.util.extensions.keyguardManager
|
import app.passwordstore.util.extensions.keyguardManager
|
||||||
import app.passwordstore.util.extensions.viewBinding
|
import app.passwordstore.util.extensions.viewBinding
|
||||||
import app.passwordstore.util.git.sshj.SshKey
|
|
||||||
import com.github.michaelbull.result.fold
|
import com.github.michaelbull.result.fold
|
||||||
import com.github.michaelbull.result.runCatching
|
import com.github.michaelbull.result.runCatching
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
@@ -33,24 +34,13 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
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
|
@AndroidEntryPoint
|
||||||
class SshKeyGenActivity : AppCompatActivity() {
|
class SshKeyGenActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private var keyGenType = KeyGenType.Ecdsa
|
private var sshKeyAlgorithm = SSHKeyAlgorithm.ECDSA
|
||||||
private val binding by viewBinding(ActivitySshKeygenBinding::inflate)
|
private val binding by viewBinding(ActivitySshKeygenBinding::inflate)
|
||||||
@GitPreferences @Inject lateinit var gitPrefs: SharedPreferences
|
@GitPreferences @Inject lateinit var gitPrefs: SharedPreferences
|
||||||
|
@Inject lateinit var sshKeyManager: SSHKeyManager
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -58,7 +48,7 @@ class SshKeyGenActivity : AppCompatActivity() {
|
|||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
with(binding) {
|
with(binding) {
|
||||||
generate.setOnClickListener {
|
generate.setOnClickListener {
|
||||||
if (SshKey.exists) {
|
if (sshKeyManager.keyExists()) {
|
||||||
MaterialAlertDialogBuilder(this@SshKeyGenActivity).run {
|
MaterialAlertDialogBuilder(this@SshKeyGenActivity).run {
|
||||||
setTitle(R.string.ssh_keygen_existing_title)
|
setTitle(R.string.ssh_keygen_existing_title)
|
||||||
setMessage(R.string.ssh_keygen_existing_message)
|
setMessage(R.string.ssh_keygen_existing_message)
|
||||||
@@ -79,18 +69,18 @@ class SshKeyGenActivity : AppCompatActivity() {
|
|||||||
keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa)
|
keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa)
|
||||||
keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
if (isChecked) {
|
if (isChecked) {
|
||||||
keyGenType =
|
sshKeyAlgorithm =
|
||||||
when (checkedId) {
|
when (checkedId) {
|
||||||
R.id.key_type_ed25519 -> KeyGenType.Ed25519
|
R.id.key_type_ed25519 -> SSHKeyAlgorithm.ED25519
|
||||||
R.id.key_type_ecdsa -> KeyGenType.Ecdsa
|
R.id.key_type_ecdsa -> SSHKeyAlgorithm.ECDSA
|
||||||
R.id.key_type_rsa -> KeyGenType.Rsa
|
R.id.key_type_rsa -> SSHKeyAlgorithm.RSA
|
||||||
else -> throw IllegalStateException("Impossible key type selection")
|
else -> throw IllegalStateException("Impossible key type selection")
|
||||||
}
|
}
|
||||||
keyTypeExplanation.setText(
|
keyTypeExplanation.setText(
|
||||||
when (keyGenType) {
|
when (sshKeyAlgorithm) {
|
||||||
KeyGenType.Ed25519 -> R.string.ssh_keygen_explanation_ed25519
|
SSHKeyAlgorithm.ED25519 -> R.string.ssh_keygen_explanation_ed25519
|
||||||
KeyGenType.Ecdsa -> R.string.ssh_keygen_explanation_ecdsa
|
SSHKeyAlgorithm.ECDSA -> R.string.ssh_keygen_explanation_ecdsa
|
||||||
KeyGenType.Rsa -> R.string.ssh_keygen_explanation_rsa
|
SSHKeyAlgorithm.RSA -> R.string.ssh_keygen_explanation_rsa
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -136,9 +126,10 @@ class SshKeyGenActivity : AppCompatActivity() {
|
|||||||
if (result !is Result.Success)
|
if (result !is Result.Success)
|
||||||
throw UserNotAuthenticatedException(getString(R.string.biometric_auth_generic_failure))
|
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") }
|
gitPrefs.edit { remove("ssh_key_local_passphrase") }
|
||||||
binding.generate.apply {
|
binding.generate.apply {
|
||||||
text = getString(R.string.ssh_keygen_generate)
|
text = getString(R.string.ssh_keygen_generate)
|
||||||
|
@@ -10,14 +10,21 @@ import android.os.Bundle
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import app.passwordstore.R
|
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.onFailure
|
||||||
import com.github.michaelbull.result.runCatching
|
import com.github.michaelbull.result.runCatching
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
class SshKeyImportActivity : AppCompatActivity() {
|
class SshKeyImportActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
@Inject lateinit var sshKeyManager: SSHKeyManager
|
||||||
|
|
||||||
private val sshKeyImportAction =
|
private val sshKeyImportAction =
|
||||||
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
|
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
@@ -25,15 +32,17 @@ class SshKeyImportActivity : AppCompatActivity() {
|
|||||||
return@registerForActivityResult
|
return@registerForActivityResult
|
||||||
}
|
}
|
||||||
runCatching {
|
runCatching {
|
||||||
SshKey.import(uri)
|
lifecycleScope.launch {
|
||||||
Toast.makeText(
|
sshKeyManager.importKey(uri)
|
||||||
this,
|
Toast.makeText(
|
||||||
resources.getString(R.string.ssh_key_success_dialog_title),
|
this@SshKeyImportActivity,
|
||||||
Toast.LENGTH_LONG
|
resources.getString(R.string.ssh_key_success_dialog_title),
|
||||||
)
|
Toast.LENGTH_LONG
|
||||||
.show()
|
)
|
||||||
setResult(RESULT_OK)
|
.show()
|
||||||
finish()
|
setResult(RESULT_OK)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
MaterialAlertDialogBuilder(this)
|
MaterialAlertDialogBuilder(this)
|
||||||
@@ -46,8 +55,8 @@ class SshKeyImportActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
if (SshKey.exists) {
|
if (sshKeyManager.keyExists()) {
|
||||||
MaterialAlertDialogBuilder(this).run {
|
MaterialAlertDialogBuilder(this@SshKeyImportActivity).run {
|
||||||
setTitle(R.string.ssh_keygen_existing_title)
|
setTitle(R.string.ssh_keygen_existing_title)
|
||||||
setMessage(R.string.ssh_keygen_existing_message)
|
setMessage(R.string.ssh_keygen_existing_message)
|
||||||
setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> importSshKey() }
|
setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> importSshKey() }
|
||||||
|
@@ -10,6 +10,7 @@ import androidx.appcompat.app.AppCompatActivity
|
|||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import app.passwordstore.R
|
import app.passwordstore.R
|
||||||
import app.passwordstore.data.repo.PasswordRepository
|
import app.passwordstore.data.repo.PasswordRepository
|
||||||
|
import app.passwordstore.ssh.SSHKeyManager
|
||||||
import app.passwordstore.ui.sshkeygen.SshKeyGenActivity
|
import app.passwordstore.ui.sshkeygen.SshKeyGenActivity
|
||||||
import app.passwordstore.ui.sshkeygen.SshKeyImportActivity
|
import app.passwordstore.ui.sshkeygen.SshKeyImportActivity
|
||||||
import app.passwordstore.util.auth.BiometricAuthenticator
|
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.auth.BiometricAuthenticator.Result.Success
|
||||||
import app.passwordstore.util.git.GitCommandExecutor
|
import app.passwordstore.util.git.GitCommandExecutor
|
||||||
import app.passwordstore.util.git.sshj.SshAuthMethod
|
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.git.sshj.SshjSessionFactory
|
||||||
import app.passwordstore.util.settings.AuthMode
|
import app.passwordstore.util.settings.AuthMode
|
||||||
import com.github.michaelbull.result.Err
|
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.onFailure
|
||||||
import com.github.michaelbull.result.runCatching
|
import com.github.michaelbull.result.runCatching
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
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.resume
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -62,6 +66,12 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
|
|||||||
open val requiresAuth: Boolean = true
|
open val requiresAuth: Boolean = true
|
||||||
private val hostKeyFile = callingActivity.filesDir.resolve(".host_key")
|
private val hostKeyFile = callingActivity.filesDir.resolve(".host_key")
|
||||||
private var sshSessionFactory: SshjSessionFactory? = null
|
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 repository = PasswordRepository.repository!!
|
||||||
protected val git = Git(repository)
|
protected val git = Git(repository)
|
||||||
private val authActivity
|
private val authActivity
|
||||||
@@ -115,7 +125,7 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
|
|||||||
authMethod: SshAuthMethod,
|
authMethod: SshAuthMethod,
|
||||||
credentialsProvider: CredentialsProvider? = null
|
credentialsProvider: CredentialsProvider? = null
|
||||||
) {
|
) {
|
||||||
sshSessionFactory = SshjSessionFactory(authMethod, hostKeyFile)
|
sshSessionFactory = SshjSessionFactory(authMethod, hostKeyFile, sshKeyManager)
|
||||||
commands.filterIsInstance<TransportCommand<*, *>>().forEach { command ->
|
commands.filterIsInstance<TransportCommand<*, *>>().forEach { command ->
|
||||||
command.setTransportConfigCallback { transport: Transport ->
|
command.setTransportConfigCallback { transport: Transport ->
|
||||||
(transport as? SshTransport)?.sshSessionFactory = sshSessionFactory
|
(transport as? SshTransport)?.sshSessionFactory = sshSessionFactory
|
||||||
@@ -163,8 +173,8 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
|
|||||||
suspend fun executeAfterAuthentication(authMode: AuthMode): Result<Unit, Throwable> {
|
suspend fun executeAfterAuthentication(authMode: AuthMode): Result<Unit, Throwable> {
|
||||||
when (authMode) {
|
when (authMode) {
|
||||||
AuthMode.SshKey ->
|
AuthMode.SshKey ->
|
||||||
if (SshKey.exists) {
|
if (sshKeyManager.keyExists()) {
|
||||||
if (SshKey.mustAuthenticate) {
|
if (sshKeyManager.needsAuthentication()) {
|
||||||
val result =
|
val result =
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
suspendCoroutine<BiometricAuthenticator.Result> { cont ->
|
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. */
|
/** Timeout in seconds before [TransportCommand] will abort a stalled IO operation. */
|
||||||
private const val CONNECT_TIMEOUT = 10
|
private const val CONNECT_TIMEOUT = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface GitOperationEntryPoint {
|
||||||
|
fun sshKeyManager(): SSHKeyManager
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -6,6 +6,7 @@ package app.passwordstore.util.git.sshj
|
|||||||
|
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import app.passwordstore.ssh.SSHKeyManager
|
||||||
import app.passwordstore.util.git.operation.CredentialFinder
|
import app.passwordstore.util.git.operation.CredentialFinder
|
||||||
import app.passwordstore.util.settings.AuthMode
|
import app.passwordstore.util.settings.AuthMode
|
||||||
import com.github.michaelbull.result.getOrElse
|
import com.github.michaelbull.result.getOrElse
|
||||||
@@ -65,8 +66,11 @@ abstract class InteractivePasswordFinder : PasswordFinder {
|
|||||||
final override fun shouldRetry(resource: Resource<*>?) = true
|
final override fun shouldRetry(resource: Resource<*>?) = true
|
||||||
}
|
}
|
||||||
|
|
||||||
class SshjSessionFactory(private val authMethod: SshAuthMethod, private val hostKeyFile: File) :
|
class SshjSessionFactory(
|
||||||
SshSessionFactory() {
|
private val authMethod: SshAuthMethod,
|
||||||
|
private val hostKeyFile: File,
|
||||||
|
private val sshKeyManager: SSHKeyManager,
|
||||||
|
) : SshSessionFactory() {
|
||||||
|
|
||||||
private var currentSession: SshjSession? = null
|
private var currentSession: SshjSession? = null
|
||||||
|
|
||||||
@@ -77,7 +81,7 @@ class SshjSessionFactory(private val authMethod: SshAuthMethod, private val host
|
|||||||
tms: Int
|
tms: Int
|
||||||
): RemoteSession {
|
): RemoteSession {
|
||||||
return currentSession
|
return currentSession
|
||||||
?: SshjSession(uri, uri.user, authMethod, hostKeyFile).connect().also {
|
?: SshjSession(uri, uri.user, authMethod, hostKeyFile, sshKeyManager).connect().also {
|
||||||
logcat { "New SSH connection created" }
|
logcat { "New SSH connection created" }
|
||||||
currentSession = it
|
currentSession = it
|
||||||
}
|
}
|
||||||
@@ -120,7 +124,8 @@ private class SshjSession(
|
|||||||
uri: URIish,
|
uri: URIish,
|
||||||
private val username: String,
|
private val username: String,
|
||||||
private val authMethod: SshAuthMethod,
|
private val authMethod: SshAuthMethod,
|
||||||
private val hostKeyFile: File
|
private val hostKeyFile: File,
|
||||||
|
private val sshKeyManager: SSHKeyManager,
|
||||||
) : RemoteSession {
|
) : RemoteSession {
|
||||||
|
|
||||||
private lateinit var ssh: SSHClient
|
private lateinit var ssh: SSHClient
|
||||||
@@ -154,7 +159,9 @@ private class SshjSession(
|
|||||||
}
|
}
|
||||||
is SshAuthMethod.SshKey -> {
|
is SshAuthMethod.SshKey -> {
|
||||||
val pubkeyAuth =
|
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)
|
ssh.auth(username, pubkeyAuth, passwordAuth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
9
detekt-baselines/ssh.xml
Normal file
9
detekt-baselines/ssh.xml
Normal file
@@ -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>
|
@@ -186,3 +186,5 @@ include("passgen:random")
|
|||||||
include("sentry-stub")
|
include("sentry-stub")
|
||||||
|
|
||||||
include("ui-compose")
|
include("ui-compose")
|
||||||
|
|
||||||
|
include("ssh")
|
||||||
|
27
ssh/build.gradle.kts
Normal file
27
ssh/build.gradle.kts
Normal file
@@ -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)
|
||||||
|
}
|
4
ssh/lint-baseline.xml
Normal file
4
ssh/lint-baseline.xml
Normal file
@@ -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>
|
5
ssh/src/main/kotlin/app/passwordstore/ssh/SSHKey.kt
Normal file
5
ssh/src/main/kotlin/app/passwordstore/ssh/SSHKey.kt
Normal file
@@ -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)
|
@@ -0,0 +1,7 @@
|
|||||||
|
package app.passwordstore.ssh
|
||||||
|
|
||||||
|
public enum class SSHKeyAlgorithm {
|
||||||
|
RSA,
|
||||||
|
ECDSA,
|
||||||
|
ED25519,
|
||||||
|
}
|
273
ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyManager.kt
Normal file
273
ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyManager.kt
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
15
ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyType.kt
Normal file
15
ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyType.kt
Normal file
@@ -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]
|
||||||
|
}
|
||||||
|
}
|
@@ -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
|
||||||
|
}
|
||||||
|
}
|
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
@@ -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
|
||||||
|
}
|
||||||
|
}
|
@@ -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
|
||||||
|
}
|
||||||
|
}
|
@@ -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)
|
||||||
|
}
|
@@ -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)
|
||||||
|
}
|
11
ssh/src/main/kotlin/app/passwordstore/ssh/utils/Constants.kt
Normal file
11
ssh/src/main/kotlin/app/passwordstore/ssh/utils/Constants.kt
Normal file
@@ -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"
|
||||||
|
}
|
@@ -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)
|
@@ -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
|
||||||
|
)
|
||||||
|
}
|
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
@@ -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)
|
||||||
|
}
|
5
ssh/src/main/res/values/strings.xml
Normal file
5
ssh/src/main/res/values/strings.xml
Normal file
@@ -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>
|
Reference in New Issue
Block a user