summaryrefslogtreecommitdiff
path: root/ssh
diff options
context:
space:
mode:
Diffstat (limited to 'ssh')
-rw-r--r--ssh/build.gradle.kts27
-rw-r--r--ssh/lint-baseline.xml4
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/SSHKey.kt5
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyAlgorithm.kt7
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyManager.kt273
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyType.kt15
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/generator/ECDSAKeyGenerator.kt53
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/generator/ED25519KeyGenerator.kt12
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/generator/RSAKeyGenerator.kt54
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/generator/SSHKeyGenerator.kt11
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/provider/KeystoreNativeKeyProvider.kt35
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/provider/KeystoreWrappedEd25519KeyProvider.kt55
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/utils/Constants.kt11
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/utils/Exceptions.kt12
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/utils/Extensions.kt49
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/utils/SSHKeyUtils.kt52
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/writer/ED25519KeyWriter.kt38
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/writer/ImportedKeyWriter.kt12
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/writer/KeystoreNativeKeyWriter.kt14
-rw-r--r--ssh/src/main/kotlin/app/passwordstore/ssh/writer/SSHKeyWriter.kt9
-rw-r--r--ssh/src/main/res/values/strings.xml5
21 files changed, 753 insertions, 0 deletions
diff --git a/ssh/build.gradle.kts b/ssh/build.gradle.kts
new file mode 100644
index 00000000..80e6f58c
--- /dev/null
+++ b/ssh/build.gradle.kts
@@ -0,0 +1,27 @@
+/*
+ * Copyright © The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+@file:Suppress("UnstableApiUsage")
+
+plugins {
+ id("com.github.android-password-store.android-library")
+ id("com.github.android-password-store.kotlin-android")
+ id("com.github.android-password-store.kotlin-library")
+}
+
+android {
+ namespace = "app.passwordstore.ssh"
+ sourceSets { getByName("test") { resources.srcDir("src/main/res/raw") } }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.kotlin.coroutines.android)
+ implementation(libs.kotlin.coroutines.core)
+ implementation(libs.thirdparty.sshj) { exclude(group = "org.bouncycastle") }
+ implementation(libs.thirdparty.logcat)
+ implementation(libs.androidx.security)
+ implementation(libs.thirdparty.eddsa)
+ implementation(libs.thirdparty.kotlinResult)
+}
diff --git a/ssh/lint-baseline.xml b/ssh/lint-baseline.xml
new file mode 100644
index 00000000..0722790e
--- /dev/null
+++ b/ssh/lint-baseline.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<issues format="6" by="lint 7.4.2" type="baseline" client="gradle" dependencies="false" name="AGP (7.4.2)" variant="all" version="7.4.2">
+
+</issues>
diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/SSHKey.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/SSHKey.kt
new file mode 100644
index 00000000..a9b7dba2
--- /dev/null
+++ b/ssh/src/main/kotlin/app/passwordstore/ssh/SSHKey.kt
@@ -0,0 +1,5 @@
+package app.passwordstore.ssh
+
+import java.io.File
+
+public data class SSHKey(val privateKey: File, val publicKey: File, val type: SSHKeyType)
diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyAlgorithm.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyAlgorithm.kt
new file mode 100644
index 00000000..1849c04d
--- /dev/null
+++ b/ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyAlgorithm.kt
@@ -0,0 +1,7 @@
+package app.passwordstore.ssh
+
+public enum class SSHKeyAlgorithm {
+ RSA,
+ ECDSA,
+ ED25519,
+}
diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyManager.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyManager.kt
new file mode 100644
index 00000000..ad435f98
--- /dev/null
+++ b/ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyManager.kt
@@ -0,0 +1,273 @@
+package app.passwordstore.ssh
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.provider.OpenableColumns
+import android.security.keystore.KeyInfo
+import androidx.core.content.edit
+import app.passwordstore.ssh.generator.ECDSAKeyGenerator
+import app.passwordstore.ssh.generator.ED25519KeyGenerator
+import app.passwordstore.ssh.generator.RSAKeyGenerator
+import app.passwordstore.ssh.provider.KeystoreNativeKeyProvider
+import app.passwordstore.ssh.provider.KeystoreWrappedEd25519KeyProvider
+import app.passwordstore.ssh.utils.Constants
+import app.passwordstore.ssh.utils.Constants.ANDROIDX_SECURITY_KEYSET_PREF_NAME
+import app.passwordstore.ssh.utils.Constants.KEYSTORE_ALIAS
+import app.passwordstore.ssh.utils.Constants.PROVIDER_ANDROID_KEY_STORE
+import app.passwordstore.ssh.utils.NullKeyException
+import app.passwordstore.ssh.utils.SSHKeyNotFoundException
+import app.passwordstore.ssh.utils.SSHKeyUtils
+import app.passwordstore.ssh.utils.getEncryptedGitPrefs
+import app.passwordstore.ssh.utils.sharedPrefs
+import app.passwordstore.ssh.writer.ED25519KeyWriter
+import app.passwordstore.ssh.writer.ImportedKeyWriter
+import app.passwordstore.ssh.writer.KeystoreNativeKeyWriter
+import com.github.michaelbull.result.getOrElse
+import com.github.michaelbull.result.runCatching
+import java.io.File
+import java.io.IOException
+import java.io.InputStream
+import java.security.KeyFactory
+import java.security.KeyPair
+import java.security.KeyStore
+import java.security.PrivateKey
+import javax.crypto.SecretKey
+import javax.crypto.SecretKeyFactory
+import logcat.asLog
+import logcat.logcat
+import net.schmizz.sshj.SSHClient
+import net.schmizz.sshj.userauth.keyprovider.KeyProvider
+import net.schmizz.sshj.userauth.password.PasswordFinder
+
+public class SSHKeyManager(private val applicationContext: Context) {
+
+ private val androidKeystore: KeyStore by
+ lazy(LazyThreadSafetyMode.NONE) {
+ KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE).apply { load(null) }
+ }
+ private val isStrongBoxSupported by
+ lazy(LazyThreadSafetyMode.NONE) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
+ applicationContext.packageManager.hasSystemFeature(
+ PackageManager.FEATURE_STRONGBOX_KEYSTORE
+ )
+ else false
+ }
+
+ // Let's make this suspend so that we can use datastore's non-blocking apis
+ private fun keyType(): SSHKeyType {
+ return SSHKeyType.fromValue(
+ applicationContext.sharedPrefs.getString(Constants.GIT_REMOTE_KEY_TYPE, null)
+ )
+ ?: throw NullKeyException()
+ }
+
+ public fun keyExists(): Boolean {
+ return try {
+ keyType()
+ true
+ } catch (e: IllegalStateException) {
+ false
+ }
+ }
+
+ public fun canShowPublicKey(): Boolean =
+ runCatching {
+ keyType() in
+ listOf(
+ SSHKeyType.LegacyGenerated,
+ SSHKeyType.KeystoreNative,
+ SSHKeyType.KeystoreWrappedEd25519
+ )
+ }
+ .getOrElse { false }
+
+ public fun publicKey(): String? =
+ runCatching { createNewSSHKey(keyType = keyType()).publicKey.readText() }
+ .getOrElse {
+ return null
+ }
+
+ public fun needsAuthentication(): Boolean {
+ return runCatching {
+ val keyType = keyType()
+ if (keyType == SSHKeyType.KeystoreNative || keyType == SSHKeyType.KeystoreWrappedEd25519)
+ return false
+
+ when (val key = androidKeystore.getKey(KEYSTORE_ALIAS, null)) {
+ is PrivateKey -> {
+ val factory = KeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE)
+ factory.getKeySpec(key, KeyInfo::class.java).isUserAuthenticationRequired
+ }
+ is SecretKey -> {
+ val factory = SecretKeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE)
+ (factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo).isUserAuthenticationRequired
+ }
+ else -> throw SSHKeyNotFoundException()
+ }
+ }
+ .getOrElse { error ->
+ // It is fine to swallow the exception here since it will reappear when the key
+ // is used for SSH authentication and can then be shown in the UI.
+ logcat { error.asLog() }
+ false
+ }
+ }
+
+ public suspend fun importKey(uri: Uri) {
+ // First check whether the content at uri is likely an SSH private key.
+ val fileSize =
+ applicationContext.contentResolver
+ .query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)
+ ?.use { cursor ->
+ // Cursor returns only a single row.
+ cursor.moveToFirst()
+ cursor.getInt(0)
+ }
+ ?: throw IOException(applicationContext.getString(R.string.ssh_key_does_not_exist))
+ // We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes.
+ require(fileSize in 1 until SSH_KEY_MAX_FILE_SIZE) {
+ applicationContext.getString(R.string.ssh_key_import_error_not_an_ssh_key_message)
+ }
+ val sshKeyInputStream =
+ applicationContext.contentResolver.openInputStream(uri)
+ ?: throw IOException(applicationContext.getString(R.string.ssh_key_does_not_exist))
+
+ importKey(sshKeyInputStream)
+ }
+
+ private suspend fun importKey(sshKeyInputStream: InputStream) {
+ val lines = sshKeyInputStream.bufferedReader().readLines()
+ // The file must have more than 2 lines, and the first and last line must have private key
+ // markers.
+ check(SSHKeyUtils.isValid(lines)) {
+ applicationContext.getString(R.string.ssh_key_import_error_not_an_ssh_key_message)
+ }
+ // At this point, we are reasonably confident that we have actually been provided a private
+ // key and delete the old key.
+ deleteKey()
+ val sshKey = createNewSSHKey(keyType = SSHKeyType.Imported)
+ saveImportedKey(lines.joinToString("\n"), sshKey)
+ }
+
+ public suspend fun generateKey(algorithm: SSHKeyAlgorithm, requiresAuthentication: Boolean) {
+ deleteKey()
+ val (sshKeyGenerator, sshKeyType) =
+ when (algorithm) {
+ SSHKeyAlgorithm.RSA -> Pair(RSAKeyGenerator(), SSHKeyType.KeystoreNative)
+ SSHKeyAlgorithm.ECDSA ->
+ Pair(ECDSAKeyGenerator(isStrongBoxSupported), SSHKeyType.KeystoreNative)
+ SSHKeyAlgorithm.ED25519 -> Pair(ED25519KeyGenerator(), SSHKeyType.KeystoreWrappedEd25519)
+ }
+ val keyPair = sshKeyGenerator.generateKey(requiresAuthentication)
+ val sshKeyFile = createNewSSHKey(keyType = sshKeyType)
+ saveGeneratedKey(keyPair, sshKeyFile, requiresAuthentication)
+ }
+
+ private suspend fun saveGeneratedKey(
+ keyPair: KeyPair,
+ sshKey: SSHKey,
+ requiresAuthentication: Boolean
+ ) {
+ val sshKeyWriter =
+ when (sshKey.type) {
+ SSHKeyType.Imported ->
+ throw UnsupportedOperationException("KeyType imported is not supported with a KeyPair")
+ SSHKeyType.KeystoreNative -> KeystoreNativeKeyWriter()
+ SSHKeyType.KeystoreWrappedEd25519 ->
+ ED25519KeyWriter(applicationContext, requiresAuthentication)
+ SSHKeyType.LegacyGenerated ->
+ error("saveGeneratedKey should not be called with a legacy generated key")
+ }
+
+ sshKeyWriter.writeKeyPair(keyPair, sshKey)
+ setSSHKeyType(sshKey.type)
+ }
+
+ private suspend fun saveImportedKey(key: String, sshKey: SSHKey) {
+ val sshKeyWriter =
+ when (sshKey.type) {
+ SSHKeyType.Imported -> ImportedKeyWriter(key)
+ SSHKeyType.KeystoreNative ->
+ throw UnsupportedOperationException(
+ "KeyType KeystoreNative is not supported with a string key"
+ )
+ SSHKeyType.KeystoreWrappedEd25519 ->
+ throw UnsupportedOperationException(
+ "KeyType KeystoreWrappedEd25519 is not supported with a string key"
+ )
+ SSHKeyType.LegacyGenerated ->
+ error("saveImportedKey should not be called with a legacy generated key")
+ }
+
+ sshKeyWriter.writeKeyPair(KeyPair(null, null), sshKey)
+ setSSHKeyType(SSHKeyType.Imported)
+ }
+
+ private fun deleteKey() {
+ androidKeystore.deleteEntry(KEYSTORE_ALIAS)
+ // Remove Tink key set used by AndroidX's EncryptedFile.
+ applicationContext
+ .getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE)
+ .edit { clear() }
+ // If there's no keyType(), we'll just use SSHKeyType.Imported, since they key is going to be
+ // deleted, it does not really matter what the key type is.
+ // The other way to handle this is to return if the keyType() throws an exception.
+ val sshKey =
+ runCatching { createNewSSHKey(keyType = keyType()) }
+ .getOrElse { createNewSSHKey(keyType = SSHKeyType.Imported) }
+ if (sshKey.privateKey.isFile) {
+ sshKey.privateKey.delete()
+ }
+ if (sshKey.publicKey.isFile) {
+ sshKey.publicKey.delete()
+ }
+
+ clearSSHKeyPreferences()
+ }
+
+ public fun keyProvider(client: SSHClient, passphraseFinder: PasswordFinder): KeyProvider? {
+ val sshKeyFile =
+ runCatching { createNewSSHKey(keyType = keyType()) }
+ .getOrElse {
+ return null
+ }
+
+ return when (sshKeyFile.type) {
+ SSHKeyType.LegacyGenerated,
+ SSHKeyType.Imported -> client.loadKeys(sshKeyFile.privateKey.absolutePath, passphraseFinder)
+ SSHKeyType.KeystoreNative -> KeystoreNativeKeyProvider(androidKeystore)
+ SSHKeyType.KeystoreWrappedEd25519 ->
+ KeystoreWrappedEd25519KeyProvider(applicationContext, sshKeyFile)
+ }
+ }
+
+ private fun setSSHKeyType(sshKeyType: SSHKeyType) {
+ applicationContext.sharedPrefs.edit {
+ putString(Constants.GIT_REMOTE_KEY_TYPE, sshKeyType.value)
+ }
+ }
+
+ private fun clearSSHKeyPreferences() {
+ applicationContext.getEncryptedGitPrefs().edit { remove(Constants.SSH_KEY_LOCAL_PASSPHRASE) }
+ applicationContext.sharedPrefs.edit { remove(Constants.GIT_REMOTE_KEY_TYPE) }
+ }
+
+ private fun createNewSSHKey(
+ keyType: SSHKeyType,
+ privateKeyFileName: String = Constants.PRIVATE_SSH_KEY_FILE_NAME,
+ publicKeyFileName: String = Constants.PUBLIC_SSH_KEY_FILE_NAME
+ ): SSHKey {
+ val privateKeyFile = File(applicationContext.filesDir, privateKeyFileName)
+ val publicKeyFile = File(applicationContext.filesDir, publicKeyFileName)
+
+ return SSHKey(privateKeyFile, publicKeyFile, keyType)
+ }
+
+ private companion object {
+
+ private const val SSH_KEY_MAX_FILE_SIZE = 100_000
+ }
+}
diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyType.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyType.kt
new file mode 100644
index 00000000..4a716ac8
--- /dev/null
+++ b/ssh/src/main/kotlin/app/passwordstore/ssh/SSHKeyType.kt
@@ -0,0 +1,15 @@
+package app.passwordstore.ssh
+
+public enum class SSHKeyType(internal val value: String) {
+ Imported("imported"),
+ KeystoreNative("keystore_native"),
+ KeystoreWrappedEd25519("keystore_wrapped_ed25519"),
+ // Behaves like `Imported`, but allows to view the public key.
+ LegacyGenerated("legacy_generated"),
+ ;
+
+ public companion object {
+
+ public fun fromValue(type: String?): SSHKeyType? = values().associateBy { it.value }[type]
+ }
+}
diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/generator/ECDSAKeyGenerator.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/generator/ECDSAKeyGenerator.kt
new file mode 100644
index 00000000..b32d0933
--- /dev/null
+++ b/ssh/src/main/kotlin/app/passwordstore/ssh/generator/ECDSAKeyGenerator.kt
@@ -0,0 +1,53 @@
+package app.passwordstore.ssh.generator
+
+import android.os.Build
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import app.passwordstore.ssh.utils.Constants.KEYSTORE_ALIAS
+import app.passwordstore.ssh.utils.Constants.PROVIDER_ANDROID_KEY_STORE
+import java.security.KeyPair
+import java.security.KeyPairGenerator
+
+public class ECDSAKeyGenerator(private val isStrongBoxSupported: Boolean) : SSHKeyGenerator {
+
+ override suspend fun generateKey(requiresAuthentication: Boolean): KeyPair {
+ val algorithm = KeyProperties.KEY_ALGORITHM_EC
+
+ val parameterSpec =
+ KeyGenParameterSpec.Builder(KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN).run {
+ setKeySize(ECDSA_KEY_SIZE)
+ setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1"))
+ setDigests(KeyProperties.DIGEST_SHA256)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ setIsStrongBoxBacked(isStrongBoxSupported)
+ }
+ if (requiresAuthentication) {
+ setUserAuthenticationRequired(true)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ setUserAuthenticationParameters(
+ SSHKeyGenerator.USER_AUTHENTICATION_TIMEOUT,
+ KeyProperties.AUTH_DEVICE_CREDENTIAL
+ )
+ } else {
+ @Suppress("DEPRECATION")
+ setUserAuthenticationValidityDurationSeconds(
+ SSHKeyGenerator.USER_AUTHENTICATION_TIMEOUT
+ )
+ }
+ }
+ build()
+ }
+
+ val keyPair =
+ KeyPairGenerator.getInstance(algorithm, PROVIDER_ANDROID_KEY_STORE).run {
+ initialize(parameterSpec)
+ generateKeyPair()
+ }
+
+ return keyPair
+ }
+
+ private companion object {
+ private const val ECDSA_KEY_SIZE = 256
+ }
+}
diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/generator/ED25519KeyGenerator.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/generator/ED25519KeyGenerator.kt
new file mode 100644
index 00000000..418e9ad9
--- /dev/null
+++ b/ssh/src/main/kotlin/app/passwordstore/ssh/generator/ED25519KeyGenerator.kt
@@ -0,0 +1,12 @@
+package app.passwordstore.ssh.generator
+
+import java.security.KeyPair
+import net.i2p.crypto.eddsa.KeyPairGenerator
+
+public class ED25519KeyGenerator : SSHKeyGenerator {
+
+ override suspend fun generateKey(requiresAuthentication: Boolean): KeyPair {
+ // Generate the ed25519 key pair and encrypt the private key.
+ return KeyPairGenerator().generateKeyPair()
+ }
+}
diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/generator/RSAKeyGenerator.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/generator/RSAKeyGenerator.kt
new file mode 100644
index 00000000..54d9ae03
--- /dev/null
+++ b/ssh/src/main/kotlin/app/passwordstore/ssh/generator/RSAKeyGenerator.kt
@@ -0,0 +1,54 @@
+package app.passwordstore.ssh.generator
+
+import android.os.Build
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import app.passwordstore.ssh.utils.Constants.KEYSTORE_ALIAS
+import app.passwordstore.ssh.utils.Constants.PROVIDER_ANDROID_KEY_STORE
+import java.security.KeyPair
+import java.security.KeyPairGenerator
+
+public class RSAKeyGenerator : SSHKeyGenerator {
+
+ override suspend fun generateKey(requiresAuthentication: Boolean): KeyPair {
+ val algorithm = KeyProperties.KEY_ALGORITHM_RSA
+ // Generate Keystore-backed private key.
+ val parameterSpec =
+ KeyGenParameterSpec.Builder(KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN).run {
+ setKeySize(RSA_KEY_SIZE)
+ setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
+ setDigests(
+ KeyProperties.DIGEST_SHA1,
+ KeyProperties.DIGEST_SHA256,
+ KeyProperties.DIGEST_SHA512,
+ )
+ if (requiresAuthentication) {
+ setUserAuthenticationRequired(true)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ setUserAuthenticationParameters(
+ SSHKeyGenerator.USER_AUTHENTICATION_TIMEOUT,
+ KeyProperties.AUTH_DEVICE_CREDENTIAL
+ )
+ } else {
+ @Suppress("DEPRECATION")
+ setUserAuthenticationValidityDurationSeconds(
+ SSHKeyGenerator.USER_AUTHENTICATION_TIMEOUT
+ )
+ }
+ }
+ build()
+ }
+
+ val keyPair =
+ KeyPairGenerator.getInstance(algorithm, PROVIDER_ANDROID_KEY_STORE).run {
+ initialize(parameterSpec)
+ generateKeyPair()
+ }
+
+ return keyPair
+ }
+
+ private companion object {
+ private const val RSA_KEY_SIZE = 3072
+ }
+}
diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/generator/SSHKeyGenerator.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/generator/SSHKeyGenerator.kt
new file mode 100644
index 00000000..09a64481
--- /dev/null
+++ b/ssh/src/main/kotlin/app/passwordstore/ssh/generator/SSHKeyGenerator.kt
@@ -0,0 +1,11 @@
+package app.passwordstore.ssh.generator
+
+import java.security.KeyPair
+
+public interface SSHKeyGenerator {
+ public suspend fun generateKey(requiresAuthentication: Boolean): KeyPair
+
+ public companion object {
+ public const val USER_AUTHENTICATION_TIMEOUT: Int = 30
+ }
+}
diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/provider/KeystoreNativeKeyProvider.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/provider/KeystoreNativeKeyProvider.kt
new file mode 100644
index 00000000..68505b8b
--- /dev/null
+++ b/ssh/src/main/kotlin/app/passwordstore/ssh/provider/KeystoreNativeKeyProvider.kt
@@ -0,0 +1,35 @@
+package app.passwordstore.ssh.provider
+
+import app.passwordstore.ssh.utils.Constants.KEYSTORE_ALIAS
+import app.passwordstore.ssh.utils.sshPrivateKey
+import app.passwordstore.ssh.utils.sshPublicKey
+import java.io.IOException
+import java.security.KeyStore
+import java.security.PrivateKey
+import java.security.PublicKey
+import logcat.asLog
+import logcat.logcat
+import net.schmizz.sshj.common.KeyType
+import net.schmizz.sshj.userauth.keyprovider.KeyProvider
+
+internal class KeystoreNativeKeyProvider(private val androidKeystore: KeyStore) : KeyProvider {
+
+ override fun getPublic(): PublicKey =
+ runCatching { androidKeystore.sshPublicKey!! }
+ .getOrElse { error ->
+ logcat { error.asLog() }
+ throw IOException("Failed to get public key '$KEYSTORE_ALIAS' from Android Keystore", error)
+ }
+
+ override fun getPrivate(): PrivateKey =
+ runCatching { androidKeystore.sshPrivateKey!! }
+ .getOrElse { error ->
+ logcat { error.asLog() }
+ throw IOException(
+ "Failed to access private key '$KEYSTORE_ALIAS' from Android Keystore",
+ error
+ )
+ }
+
+ override fun getType(): KeyType = KeyType.fromKey(public)
+}
diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/provider/KeystoreWrappedEd25519KeyProvider.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/provider/KeystoreWrappedEd25519KeyProvider.kt
new file mode 100644
index 00000000..31a57998
--- /dev/null
+++ b/ssh/src/main/kotlin/app/passwordstore/ssh/provider/KeystoreWrappedEd25519KeyProvider.kt
@@ -0,0 +1,55 @@
+package app.passwordstore.ssh.provider
+
+import android.content.Context
+import app.passwordstore.ssh.SSHKey
+import app.passwordstore.ssh.utils.SSHKeyUtils.getOrCreateWrappedPrivateKeyFile
+import app.passwordstore.ssh.utils.parseStringPublicKey
+import com.github.michaelbull.result.getOrElse
+import com.github.michaelbull.result.runCatching
+import java.io.IOException
+import java.security.PrivateKey
+import java.security.PublicKey
+import kotlinx.coroutines.runBlocking
+import logcat.asLog
+import logcat.logcat
+import net.i2p.crypto.eddsa.EdDSAPrivateKey
+import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable
+import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec
+import net.schmizz.sshj.common.KeyType
+import net.schmizz.sshj.userauth.keyprovider.KeyProvider
+
+internal class KeystoreWrappedEd25519KeyProvider(
+ private val context: Context,
+ private val sshKeyFile: SSHKey
+) : KeyProvider {
+
+ override fun getPublic(): PublicKey =
+ runCatching { sshKeyFile.publicKey.readText().parseStringPublicKey()!! }
+ .getOrElse { error ->
+ logcat { error.asLog() }
+ throw IOException("Failed to get the public key for wrapped ed25519 key", error)
+ }
+
+ override fun getPrivate(): PrivateKey =
+ runCatching {
+ // The current MasterKey API does not allow getting a reference to an existing
+ // one
+ // without specifying the KeySpec for a new one. However, the value for passed
+ // here
+ // for `requireAuthentication` is not used as the key already exists at this
+ // point.
+ val encryptedPrivateKeyFile = runBlocking {
+ getOrCreateWrappedPrivateKeyFile(context, false, sshKeyFile.privateKey)
+ }
+ val rawPrivateKey = encryptedPrivateKeyFile.openFileInput().use { it.readBytes() }
+ EdDSAPrivateKey(
+ EdDSAPrivateKeySpec(rawPrivateKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC)
+ )
+ }
+ .getOrElse { error ->
+ logcat { error.asLog() }
+ throw IOException("Failed to unwrap wrapped ed25519 key", error)
+ }
+
+ override fun getType(): KeyType = KeyType.fromKey(public)
+}
diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/utils/Constants.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/utils/Constants.kt
new file mode 100644
index 00000000..ccc33094
--- /dev/null
+++ b/ssh/src/main/kotlin/app/passwordstore/ssh/utils/Constants.kt
@@ -0,0 +1,11 @@
+package app.passwordstore.ssh.utils
+
+internal object Constants {
+ const val ANDROIDX_SECURITY_KEYSET_PREF_NAME = "androidx_sshkey_keyset_prefs"
+ const val GIT_REMOTE_KEY_TYPE = "git_remote_key_type"
+ const val KEYSTORE_ALIAS = "sshkey"
+ const val PRIVATE_SSH_KEY_FILE_NAME = ".ssh_key"
+ const val PROVIDER_ANDROID_KEY_STORE = "AndroidKeyStore"
+ const val PUBLIC_SSH_KEY_FILE_NAME = ".ssh_key.pub"
+ const val SSH_KEY_LOCAL_PASSPHRASE = "ssh_key_local_passphrase"
+}
diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/utils/Exceptions.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/utils/Exceptions.kt
new file mode 100644
index 00000000..db921ab6
--- /dev/null
+++ b/ssh/src/main/kotlin/app/passwordstore/ssh/utils/Exceptions.kt
@@ -0,0 +1,12 @@
+package app.passwordstore.ssh.utils
+
+public sealed class SSHException(message: String? = null, cause: Throwable? = null) :
+ Exception(message, cause)
+
+public class NullKeyException(message: String? = "keyType was null", cause: Throwable? = null) :
+ SSHException(message, cause)
+
+public class SSHKeyNotFoundException(
+ message: String? = "SSH key does not exist in Keystore",
+ cause: Throwable? = null
+) : SSHException(message, cause)
diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/utils/Extensions.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/utils/Extensions.kt
new file mode 100644
index 00000000..e1c337c1
--- /dev/null
+++ b/ssh/src/main/kotlin/app/passwordstore/ssh/utils/Extensions.kt
@@ -0,0 +1,49 @@
+package app.passwordstore.ssh.utils
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.util.Base64
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import app.passwordstore.ssh.utils.Constants.KEYSTORE_ALIAS
+import java.security.KeyStore
+import java.security.PrivateKey
+import java.security.PublicKey
+import net.schmizz.sshj.common.Buffer
+import net.schmizz.sshj.common.KeyType
+
+/** Get the default [SharedPreferences] instance */
+internal val Context.sharedPrefs: SharedPreferences
+ get() = getSharedPreferences("app.passwordstore_preferences", 0)
+internal val KeyStore.sshPrivateKey
+ get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey
+internal val KeyStore.sshPublicKey
+ get() = getCertificate(KEYSTORE_ALIAS)?.publicKey
+
+internal fun String.parseStringPublicKey(): PublicKey? {
+ val sshKeyParts = this.split("""\s+""".toRegex())
+ if (sshKeyParts.size < 2) return null
+ return Buffer.PlainBuffer(Base64.decode(sshKeyParts[1], Base64.NO_WRAP)).readPublicKey()
+}
+
+internal fun PublicKey.createStringPublicKey(): String {
+ val rawPublicKey = Buffer.PlainBuffer().putPublicKey(this).compactData
+ val keyType = KeyType.fromKey(this)
+ return "$keyType ${Base64.encodeToString(rawPublicKey, Base64.NO_WRAP)}"
+}
+
+/** Wrapper for [getEncryptedPrefs] to avoid open-coding the file name at each call site */
+internal fun Context.getEncryptedGitPrefs() = getEncryptedPrefs("git_operation")
+
+/** Get an instance of [EncryptedSharedPreferences] with the given [fileName] */
+private fun Context.getEncryptedPrefs(fileName: String): SharedPreferences {
+ val masterKeyAlias =
+ MasterKey.Builder(applicationContext).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
+ return EncryptedSharedPreferences.create(
+ applicationContext,
+ fileName,
+ masterKeyAlias,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+}
diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/utils/SSHKeyUtils.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/utils/SSHKeyUtils.kt
new file mode 100644
index 00000000..86ce478e
--- /dev/null
+++ b/ssh/src/main/kotlin/app/passwordstore/ssh/utils/SSHKeyUtils.kt
@@ -0,0 +1,52 @@
+package app.passwordstore.ssh.utils
+
+import android.content.Context
+import androidx.security.crypto.EncryptedFile
+import androidx.security.crypto.MasterKey
+import java.io.File
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+internal object SSHKeyUtils {
+
+ private const val USER_AUTHENTICATION_VALIDITY_DURATION = 15
+
+ fun isValid(lines: List<String>): Boolean {
+ return lines.size > 2 &&
+ Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) &&
+ Regex("END .* PRIVATE KEY").containsMatchIn(lines.last())
+ }
+
+ @Suppress("BlockingMethodInNonBlockingContext")
+ suspend fun getOrCreateWrappedPrivateKeyFile(
+ context: Context,
+ requiresAuthentication: Boolean,
+ privateKeyFile: File
+ ) =
+ withContext(Dispatchers.IO) {
+ EncryptedFile.Builder(
+ context,
+ privateKeyFile,
+ getOrCreateWrappingMasterKey(context, requiresAuthentication),
+ EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
+ )
+ .run {
+ setKeysetPrefName(Constants.ANDROIDX_SECURITY_KEYSET_PREF_NAME)
+ build()
+ }
+ }
+
+ @Suppress("BlockingMethodInNonBlockingContext")
+ private suspend fun getOrCreateWrappingMasterKey(
+ context: Context,
+ requireAuthentication: Boolean
+ ) =
+ withContext(Dispatchers.IO) {
+ MasterKey.Builder(context, Constants.KEYSTORE_ALIAS).run {
+ setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ setRequestStrongBoxBacked(true)
+ setUserAuthenticationRequired(requireAuthentication, USER_AUTHENTICATION_VALIDITY_DURATION)
+ build()
+ }
+ }
+}
diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/writer/ED25519KeyWriter.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/writer/ED25519KeyWriter.kt
new file mode 100644
index 00000000..ac2c983c
--- /dev/null
+++ b/ssh/src/main/kotlin/app/passwordstore/ssh/writer/ED25519KeyWriter.kt
@@ -0,0 +1,38 @@
+package app.passwordstore.ssh.writer
+
+import android.content.Context
+import app.passwordstore.ssh.SSHKey
+import app.passwordstore.ssh.utils.SSHKeyUtils.getOrCreateWrappedPrivateKeyFile
+import app.passwordstore.ssh.utils.createStringPublicKey
+import java.io.File
+import java.security.KeyPair
+import java.security.PrivateKey
+import java.security.PublicKey
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import net.i2p.crypto.eddsa.EdDSAPrivateKey
+
+public class ED25519KeyWriter(
+ private val context: Context,
+ private val requiresAuthentication: Boolean,
+) : SSHKeyWriter {
+
+ override suspend fun writeKeyPair(keyPair: KeyPair, sshKeyFile: SSHKey) {
+ writePrivateKey(keyPair.private, sshKeyFile.privateKey)
+ writePublicKey(keyPair.public, sshKeyFile.publicKey)
+ }
+
+ private suspend fun writePrivateKey(privateKey: PrivateKey, privateKeyFile: File) {
+ withContext(Dispatchers.IO) {
+ val encryptedPrivateKeyFile =
+ getOrCreateWrappedPrivateKeyFile(context, requiresAuthentication, privateKeyFile)
+ encryptedPrivateKeyFile.openFileOutput().use { os ->
+ os.write((privateKey as EdDSAPrivateKey).seed)
+ }
+ }
+ }
+
+ private suspend fun writePublicKey(publicKey: PublicKey, publicKeyFile: File) {
+ publicKeyFile.writeText(publicKey.createStringPublicKey())
+ }
+}
diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/writer/ImportedKeyWriter.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/writer/ImportedKeyWriter.kt
new file mode 100644
index 00000000..809a3d60
--- /dev/null
+++ b/ssh/src/main/kotlin/app/passwordstore/ssh/writer/ImportedKeyWriter.kt
@@ -0,0 +1,12 @@
+package app.passwordstore.ssh.writer
+
+import app.passwordstore.ssh.SSHKey
+import java.security.KeyPair
+
+public class ImportedKeyWriter(private val privateKey: String) : SSHKeyWriter {
+
+ override suspend fun writeKeyPair(keyPair: KeyPair, sshKeyFile: SSHKey) {
+ // Write the string key instead of the key from the key pair
+ sshKeyFile.privateKey.writeText(privateKey)
+ }
+}
diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/writer/KeystoreNativeKeyWriter.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/writer/KeystoreNativeKeyWriter.kt
new file mode 100644
index 00000000..24cc02b9
--- /dev/null
+++ b/ssh/src/main/kotlin/app/passwordstore/ssh/writer/KeystoreNativeKeyWriter.kt
@@ -0,0 +1,14 @@
+package app.passwordstore.ssh.writer
+
+import app.passwordstore.ssh.SSHKey
+import app.passwordstore.ssh.utils.createStringPublicKey
+import java.security.KeyPair
+
+public class KeystoreNativeKeyWriter : SSHKeyWriter {
+
+ override suspend fun writeKeyPair(keyPair: KeyPair, sshKeyFile: SSHKey) {
+ // Android Keystore manages the private key for us
+ // Write public key in SSH format to .ssh_key.pub.
+ sshKeyFile.publicKey.writeText(keyPair.public.createStringPublicKey())
+ }
+}
diff --git a/ssh/src/main/kotlin/app/passwordstore/ssh/writer/SSHKeyWriter.kt b/ssh/src/main/kotlin/app/passwordstore/ssh/writer/SSHKeyWriter.kt
new file mode 100644
index 00000000..c5866086
--- /dev/null
+++ b/ssh/src/main/kotlin/app/passwordstore/ssh/writer/SSHKeyWriter.kt
@@ -0,0 +1,9 @@
+package app.passwordstore.ssh.writer
+
+import app.passwordstore.ssh.SSHKey
+import java.security.KeyPair
+
+public interface SSHKeyWriter {
+
+ public suspend fun writeKeyPair(keyPair: KeyPair, sshKeyFile: SSHKey)
+}
diff --git a/ssh/src/main/res/values/strings.xml b/ssh/src/main/res/values/strings.xml
new file mode 100644
index 00000000..f35fe0ac
--- /dev/null
+++ b/ssh/src/main/res/values/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="ssh_key_does_not_exist">Unable to open the ssh private key, please check that the file exists</string>
+ <string name="ssh_key_import_error_not_an_ssh_key_message">Selected file does not appear to be an SSH private key.</string>
+</resources>