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:
Aditya Wasan 2023-04-02 14:04:33 -04:00 committed by GitHub
parent 577d6ab55a
commit 97b3577a46
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 877 additions and 55 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,9 +32,10 @@ class SshKeyImportActivity : AppCompatActivity() {
return@registerForActivityResult
}
runCatching {
SshKey.import(uri)
lifecycleScope.launch {
sshKeyManager.importKey(uri)
Toast.makeText(
this,
this@SshKeyImportActivity,
resources.getString(R.string.ssh_key_success_dialog_title),
Toast.LENGTH_LONG
)
@ -35,6 +43,7 @@ class SshKeyImportActivity : AppCompatActivity() {
setResult(RESULT_OK)
finish()
}
}
.onFailure { e ->
MaterialAlertDialogBuilder(this)
.setTitle(resources.getString(R.string.ssh_key_error_dialog_title))
@ -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() }

View file

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

View file

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

9
detekt-baselines/ssh.xml Normal file
View 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>

View file

@ -186,3 +186,5 @@ include("passgen:random")
include("sentry-stub")
include("ui-compose")
include("ssh")

27
ssh/build.gradle.kts Normal file
View 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
View 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>

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

View file

@ -0,0 +1,7 @@
package app.passwordstore.ssh
public enum class SSHKeyAlgorithm {
RSA,
ECDSA,
ED25519,
}

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

View 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]
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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"
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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