diff options
author | Aditya Wasan <adityawasan55@gmail.com> | 2023-04-02 14:04:33 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-04-02 18:04:33 +0000 |
commit | 97b3577a463966e93d24649ff348fc4bb6825e50 (patch) | |
tree | 8662fa55b2ab85fba58b3d8f7e3fe036bc0475f6 /ssh/src | |
parent | 577d6ab55a331fe842fed25fdf96b22bac345d90 (diff) |
Refactor SSHKey into a separate module (#2450)
* refactor(ssh): add `ssh` module
Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>
* refactor(ssh): add `SSHKey` data class
Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>
* refactor(ssh): add `SSHKeyType` enum
Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>
* refactor(ssh): add `SSHKeyAlgorithm` class
Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>
* refactor(ssh): add class to generate `RSA` key
Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>
* chore(ssh): add required dependencies
Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>
* refactor(ssh): add `ECDSAKeyGenerator` and remove constants
Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>
* refactor(ssh): add utilities
Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>
* feat(ssh): add `SSHKeyWriter`
Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>
* refactor(ssh): make ssh key generators suspending
Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>
* fix(ssh): fix explicit API violations
* feat: complete `ED25519KeyWriter` implementation
Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>
* factor(ssh/writer): update writer interface
Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>
* feat(ssh/provider): add providers for different key types
Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>
* feat(ssh): add SSHKeyManager for common key functionality
Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>
* feat(ssh): add remaining methods to reach feature parity with old SSH implementation
Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>
* wip(app): start using SSHKeyManager instead of SSHKey class
Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
* refactor(ssh): update package name
Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>
* chore(ssh): fix detekt warnings
Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>
* chore: fixes across the board
---------
Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
Co-authored-by: Harsh Shandilya <me@msfjarvis.dev>
Diffstat (limited to 'ssh/src')
19 files changed, 722 insertions, 0 deletions
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> |