From cbb96397d1f5cffef7984b08a426646aaea93e5b Mon Sep 17 00:00:00 2001 From: Fabian Henneke Date: Tue, 1 Sep 2020 10:12:27 +0200 Subject: Add Keystore backend for SSH public key authentication (#1070) --- app/build.gradle.kts | 4 +- app/proguard-rules.pro | 1 - .../main/java/com/zeapo/pwdstore/Application.kt | 5 +- .../java/com/zeapo/pwdstore/ClipboardService.kt | 3 +- app/src/main/java/com/zeapo/pwdstore/Migrations.kt | 4 +- .../main/java/com/zeapo/pwdstore/PasswordStore.kt | 1 - .../main/java/com/zeapo/pwdstore/UserPreference.kt | 82 +++--- .../zeapo/pwdstore/autofill/oreo/AutofillHelper.kt | 1 - .../java/com/zeapo/pwdstore/git/ErrorMessages.kt | 2 + .../com/zeapo/pwdstore/git/GitCommandExecutor.kt | 2 +- .../zeapo/pwdstore/git/operation/GitOperation.kt | 80 +++-- .../pwdstore/git/sshj/OpenKeychainKeyProvider.kt | 10 +- .../java/com/zeapo/pwdstore/git/sshj/SshKey.kt | 321 +++++++++++++++++++++ .../java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt | 4 + .../zeapo/pwdstore/git/sshj/SshjSessionFactory.kt | 6 +- .../zeapo/pwdstore/sshkeygen/ShowSshKeyFragment.kt | 62 ++-- .../zeapo/pwdstore/sshkeygen/SshKeyGenActivity.kt | 99 +++++-- .../zeapo/pwdstore/utils/BiometricAuthenticator.kt | 5 +- .../java/com/zeapo/pwdstore/utils/Extensions.kt | 4 + .../com/zeapo/pwdstore/utils/PreferenceKeys.kt | 3 +- app/src/main/res/layout/activity_ssh_keygen.xml | 53 ++-- app/src/main/res/layout/folder_dialog_fragment.xml | 2 +- app/src/main/res/layout/fragment_show_ssh_key.xml | 32 -- app/src/main/res/layout/git_credential_layout.xml | 2 +- app/src/main/res/values-ar/strings.xml | 1 - app/src/main/res/values-cs/strings.xml | 2 - app/src/main/res/values-de/strings.xml | 2 - app/src/main/res/values-es/strings.xml | 2 - app/src/main/res/values-fr/strings.xml | 2 - app/src/main/res/values-ja/strings.xml | 2 - app/src/main/res/values-pt-rBR/strings.xml | 2 - app/src/main/res/values-ru/strings.xml | 2 - app/src/main/res/values-zh-rCN/strings.xml | 2 - app/src/main/res/values-zh-rTW/strings.xml | 2 - app/src/main/res/values/strings.xml | 25 +- 35 files changed, 577 insertions(+), 255 deletions(-) create mode 100644 app/src/main/java/com/zeapo/pwdstore/git/sshj/SshKey.kt delete mode 100644 app/src/main/res/layout/fragment_show_ssh_key.xml (limited to 'app') diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c4a17e43..e613b79a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,8 +2,8 @@ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. * SPDX-License-Identifier: GPL-3.0-only */ -import java.util.Properties import com.android.build.gradle.internal.api.BaseVariantOutputImpl +import java.util.Properties plugins { kotlin("android") @@ -113,11 +113,11 @@ dependencies { implementation(Dependencies.FirstParty.zxing_android_embedded) implementation(Dependencies.ThirdParty.commons_codec) + implementation(Dependencies.ThirdParty.eddsa) implementation(Dependencies.ThirdParty.fastscroll) implementation(Dependencies.ThirdParty.jgit) { exclude(group = "org.apache.httpcomponents", module = "httpclient") } - implementation(Dependencies.ThirdParty.jsch) implementation(Dependencies.ThirdParty.sshj) implementation(Dependencies.ThirdParty.bouncycastle) implementation(Dependencies.ThirdParty.plumber) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 753f02ab..eaa3d830 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -18,7 +18,6 @@ -keepattributes SourceFile,LineNumberTable -dontobfuscate --keep class com.jcraft.jsch.** -keep class org.eclipse.jgit.internal.JGitText { *; } -keep class org.bouncycastle.jcajce.provider.** { *; } -keep class org.bouncycastle.jce.provider.** { *; } diff --git a/app/src/main/java/com/zeapo/pwdstore/Application.kt b/app/src/main/java/com/zeapo/pwdstore/Application.kt index 3108dee3..91e47793 100644 --- a/app/src/main/java/com/zeapo/pwdstore/Application.kt +++ b/app/src/main/java/com/zeapo/pwdstore/Application.kt @@ -14,8 +14,8 @@ import com.github.ajalt.timberkt.Timber.DebugTree import com.github.ajalt.timberkt.Timber.plant import com.zeapo.pwdstore.git.sshj.setUpBouncyCastleForSshj import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.sharedPrefs import com.zeapo.pwdstore.utils.getString +import com.zeapo.pwdstore.utils.sharedPrefs @Suppress("Unused") class Application : android.app.Application(), SharedPreferences.OnSharedPreferenceChangeListener { @@ -45,7 +45,8 @@ class Application : android.app.Application(), SharedPreferences.OnSharedPrefere } private fun setNightMode() { - AppCompatDelegate.setDefaultNightMode(when (sharedPrefs.getString(PreferenceKeys.APP_THEME) ?: getString(R.string.app_theme_def)) { + AppCompatDelegate.setDefaultNightMode(when (sharedPrefs.getString(PreferenceKeys.APP_THEME) + ?: getString(R.string.app_theme_def)) { "light" -> MODE_NIGHT_NO "dark" -> MODE_NIGHT_YES "follow_system" -> MODE_NIGHT_FOLLOW_SYSTEM diff --git a/app/src/main/java/com/zeapo/pwdstore/ClipboardService.kt b/app/src/main/java/com/zeapo/pwdstore/ClipboardService.kt index d02a333a..7493a364 100644 --- a/app/src/main/java/com/zeapo/pwdstore/ClipboardService.kt +++ b/app/src/main/java/com/zeapo/pwdstore/ClipboardService.kt @@ -46,7 +46,8 @@ class ClipboardService : Service() { ACTION_START -> { val time = try { - Integer.parseInt(settings.getString(PreferenceKeys.GENERAL_SHOW_TIME) ?: "45") + Integer.parseInt(settings.getString(PreferenceKeys.GENERAL_SHOW_TIME) + ?: "45") } catch (e: NumberFormatException) { 45 } diff --git a/app/src/main/java/com/zeapo/pwdstore/Migrations.kt b/app/src/main/java/com/zeapo/pwdstore/Migrations.kt index f7cce784..64220aed 100644 --- a/app/src/main/java/com/zeapo/pwdstore/Migrations.kt +++ b/app/src/main/java/com/zeapo/pwdstore/Migrations.kt @@ -45,8 +45,8 @@ private fun migrateToGitUrlBasedConfig(context: Context) { if (!serverPath.startsWith('/')) null else - // We have to specify the ssh scheme as this is the only way to pass a custom - // port. + // We have to specify the ssh scheme as this is the only way to pass a custom + // port. "ssh://$userPart$hostnamePart$portPart$serverPath" } } diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt index 6a0d707c..d9afc7c9 100644 --- a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt +++ b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt @@ -48,7 +48,6 @@ import com.zeapo.pwdstore.crypto.BasePgpActivity.Companion.getLongName import com.zeapo.pwdstore.crypto.DecryptActivity import com.zeapo.pwdstore.crypto.PasswordCreationActivity import com.zeapo.pwdstore.git.BaseGitActivity -import com.zeapo.pwdstore.git.log.GitLogActivity import com.zeapo.pwdstore.git.GitOperationActivity import com.zeapo.pwdstore.git.GitServerConfigActivity import com.zeapo.pwdstore.git.config.AuthMode diff --git a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt index 8f306f6b..6319ee51 100644 --- a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt +++ b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt @@ -15,7 +15,6 @@ import android.os.Build import android.os.Bundle import android.os.Environment import android.provider.DocumentsContract -import android.provider.OpenableColumns import android.provider.Settings import android.text.TextUtils import android.view.MenuItem @@ -45,6 +44,7 @@ import com.zeapo.pwdstore.autofill.oreo.getInstalledBrowsersWithAutofillSupportL import com.zeapo.pwdstore.crypto.BasePgpActivity import com.zeapo.pwdstore.git.GitConfigActivity import com.zeapo.pwdstore.git.GitServerConfigActivity +import com.zeapo.pwdstore.git.sshj.SshKey import com.zeapo.pwdstore.pwgenxkpwd.XkpwdDictionary import com.zeapo.pwdstore.sshkeygen.ShowSshKeyFragment import com.zeapo.pwdstore.sshkeygen.SshKeyGenActivity @@ -56,7 +56,6 @@ import com.zeapo.pwdstore.utils.getEncryptedPrefs import com.zeapo.pwdstore.utils.getString import com.zeapo.pwdstore.utils.sharedPrefs import java.io.File -import java.io.IOException typealias ClickListener = Preference.OnPreferenceClickListener typealias ChangeListener = Preference.OnPreferenceChangeListener @@ -69,6 +68,7 @@ class UserPreference : AppCompatActivity() { private var autoFillEnablePreference: SwitchPreferenceCompat? = null private var clearSavedPassPreference: Preference? = null + private var viewSshKeyPreference: Preference? = null private lateinit var autofillDependencies: List private lateinit var oreoAutofillDependencies: List private lateinit var prefsActivity: UserPreference @@ -89,8 +89,8 @@ class UserPreference : AppCompatActivity() { val gitConfigPreference = findPreference(PreferenceKeys.GIT_CONFIG) val sshKeyPreference = findPreference(PreferenceKeys.SSH_KEY) val sshKeygenPreference = findPreference(PreferenceKeys.SSH_KEYGEN) + viewSshKeyPreference = findPreference(PreferenceKeys.SSH_SEE_KEY) clearSavedPassPreference = findPreference(PreferenceKeys.CLEAR_SAVED_PASS) - val viewSshKeyPreference = findPreference(PreferenceKeys.SSH_SEE_KEY) val deleteRepoPreference = findPreference(PreferenceKeys.GIT_DELETE_REPO) val externalGitRepositoryPreference = findPreference(PreferenceKeys.GIT_EXTERNAL) val selectExternalGitRepositoryPreference = findPreference(PreferenceKeys.PREF_SELECT_EXTERNAL) @@ -141,8 +141,8 @@ class UserPreference : AppCompatActivity() { // Misc preferences val appVersionPreference = findPreference(PreferenceKeys.APP_VERSION) - selectExternalGitRepositoryPreference?.summary = sharedPreferences.getString(PreferenceKeys.GIT_EXTERNAL_REPO) ?: getString(R.string.no_repo_selected) - viewSshKeyPreference?.isVisible = sharedPreferences.getBoolean(PreferenceKeys.USE_GENERATED_KEY, false) + selectExternalGitRepositoryPreference?.summary = sharedPreferences.getString(PreferenceKeys.GIT_EXTERNAL_REPO) + ?: getString(R.string.no_repo_selected) deleteRepoPreference?.isVisible = !sharedPreferences.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) clearClipboard20xPreference?.isVisible = sharedPreferences.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toInt() != 0 openkeystoreIdPreference?.isVisible = sharedPreferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty() @@ -226,7 +226,8 @@ class UserPreference : AppCompatActivity() { } selectExternalGitRepositoryPreference?.summary = - sharedPreferences.getString(PreferenceKeys.GIT_EXTERNAL_REPO) ?: context.getString(R.string.no_repo_selected) + sharedPreferences.getString(PreferenceKeys.GIT_EXTERNAL_REPO) + ?: context.getString(R.string.no_repo_selected) selectExternalGitRepositoryPreference?.onPreferenceClickListener = ClickListener { prefsActivity.selectExternalGitRepository() true @@ -393,6 +394,10 @@ class UserPreference : AppCompatActivity() { } } + private fun updateViewSshPubkeyPref() { + viewSshKeyPreference?.isVisible = SshKey.canShowSshPublicKey + } + private fun onEnableAutofillClick() { if (prefsActivity.isAccessibilityServiceEnabled) { startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) @@ -451,6 +456,7 @@ class UserPreference : AppCompatActivity() { super.onResume() updateAutofillSettings() updateClearSavedPassphrasePrefs() + updateViewSshPubkeyPref() } } @@ -532,29 +538,18 @@ class UserPreference : AppCompatActivity() { } } - /** - * Opens a file explorer to import the private key - */ - private fun getSshKey() { + private fun importSshKey() { registerForActivityResult(OpenDocument()) { uri: Uri? -> if (uri == null) return@registerForActivityResult try { - copySshKey(uri) + SshKey.import(uri) Toast.makeText( this, this.resources.getString(R.string.ssh_key_success_dialog_title), Toast.LENGTH_LONG ).show() - val prefs = sharedPrefs - - prefs.edit { putBoolean(PreferenceKeys.USE_GENERATED_KEY, false) } - getEncryptedPrefs("git_operation").edit { remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) } - - // Delete the public key from generation - File("""$filesDir/.ssh_key.pub""").delete() setResult(RESULT_OK) - finish() } catch (e: Exception) { MaterialAlertDialogBuilder(this) @@ -566,6 +561,25 @@ class UserPreference : AppCompatActivity() { }.launch(arrayOf("*/*")) } + /** + * Opens a file explorer to import the private key + */ + private fun getSshKey() { + if (SshKey.exists) { + MaterialAlertDialogBuilder(this).run { + setTitle(R.string.ssh_keygen_existing_title) + setMessage(R.string.ssh_keygen_existing_message) + setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> + importSshKey() + } + setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> } + show() + } + } else { + importSshKey() + } + } + /** * Exports the passwords */ @@ -638,36 +652,6 @@ class UserPreference : AppCompatActivity() { }.launch(arrayOf("*/*")) } - @Throws(IllegalArgumentException::class, IOException::class) - private fun copySshKey(uri: Uri) { - // First check whether the content at uri is likely an SSH private key. - val fileSize = contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null) - ?.use { cursor -> - // Cursor returns only a single row. - cursor.moveToFirst() - cursor.getInt(0) - } ?: throw IOException(getString(R.string.ssh_key_does_not_exist)) - - // We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes. - if (fileSize > 100_000 || fileSize == 0) - throw IllegalArgumentException(getString(R.string.ssh_key_import_error_not_an_ssh_key_message)) - - val sshKeyInputStream = contentResolver.openInputStream(uri) - ?: throw IOException(getString(R.string.ssh_key_does_not_exist)) - val lines = sshKeyInputStream.bufferedReader().readLines() - - // The file must have more than 2 lines, and the first and last line must have private key - // markers. - if (lines.size < 2 || - !Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) || - !Regex("END .* PRIVATE KEY").containsMatchIn(lines.last()) - ) - throw IllegalArgumentException(getString(R.string.ssh_key_import_error_not_an_ssh_key_message)) - - // Canonicalize line endings to '\n'. - File("$filesDir/.ssh_key").writeText(lines.joinToString("\n")) - } - private val isAccessibilityServiceEnabled: Boolean get() { val am = getSystemService() ?: return false diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt index 14bcb501..502c9423 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt @@ -16,7 +16,6 @@ import android.view.autofill.AutofillId import android.widget.RemoteViews import android.widget.Toast import androidx.annotation.RequiresApi -import androidx.preference.PreferenceManager import com.github.ajalt.timberkt.Timber.tag import com.github.ajalt.timberkt.e import com.zeapo.pwdstore.R diff --git a/app/src/main/java/com/zeapo/pwdstore/git/ErrorMessages.kt b/app/src/main/java/com/zeapo/pwdstore/git/ErrorMessages.kt index 990cc670..89676c7a 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/ErrorMessages.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/ErrorMessages.kt @@ -19,6 +19,7 @@ sealed class GitException(@StringRes res: Int, vararg fmt: String) : Exception(b override val message = super.message!! companion object { + private fun buildMessage(@StringRes res: Int, vararg fmt: String) = Application.instance.resources.getString(res, *fmt) } @@ -26,6 +27,7 @@ sealed class GitException(@StringRes res: Int, vararg fmt: String) : Exception(b * Encapsulates possible errors from a [org.eclipse.jgit.api.PullCommand]. */ sealed class PullException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) { + object PullRebaseFailed : PullException(R.string.git_pull_fail_error) } diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt index bf35e5c7..d5956b1f 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt @@ -15,8 +15,8 @@ import com.zeapo.pwdstore.R import com.zeapo.pwdstore.git.GitException.PullException import com.zeapo.pwdstore.git.GitException.PushException import com.zeapo.pwdstore.git.config.GitSettings -import com.zeapo.pwdstore.git.sshj.SshjSessionFactory import com.zeapo.pwdstore.git.operation.GitOperation +import com.zeapo.pwdstore.git.sshj.SshjSessionFactory import com.zeapo.pwdstore.utils.Result import com.zeapo.pwdstore.utils.snackbar import kotlinx.coroutines.Dispatchers diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt index 806bbb7c..1b47d79b 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt @@ -5,6 +5,7 @@ package com.zeapo.pwdstore.git.operation import android.content.Intent +import android.widget.Toast import androidx.annotation.CallSuper import androidx.core.content.edit import androidx.fragment.app.FragmentActivity @@ -17,12 +18,18 @@ import com.zeapo.pwdstore.git.config.AuthMode import com.zeapo.pwdstore.git.config.GitSettings import com.zeapo.pwdstore.git.sshj.InteractivePasswordFinder import com.zeapo.pwdstore.git.sshj.SshAuthData +import com.zeapo.pwdstore.git.sshj.SshKey import com.zeapo.pwdstore.git.sshj.SshjSessionFactory +import com.zeapo.pwdstore.utils.BiometricAuthenticator import com.zeapo.pwdstore.utils.PasswordRepository import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.getEncryptedPrefs import com.zeapo.pwdstore.utils.sharedPrefs import java.io.File +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import net.schmizz.sshj.userauth.password.PasswordFinder import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.GitCommand @@ -33,6 +40,8 @@ import org.eclipse.jgit.transport.CredentialsProvider import org.eclipse.jgit.transport.SshSessionFactory import org.eclipse.jgit.transport.URIish +const val ANDROID_KEYSTORE_ALIAS_SSH_KEY = "ssh_key" + /** * Creates a new git operation * @@ -43,7 +52,6 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: Fragment abstract val commands: Array> private var provider: CredentialsProvider? = null - private val sshKeyFile = callingActivity.filesDir.resolve(".ssh_key") private val hostKeyFile = callingActivity.filesDir.resolve(".host_key") protected var finishFromErrorDialog = true protected val repository = PasswordRepository.getRepository(gitDir) @@ -61,9 +69,10 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: Fragment when (item) { is CredentialItem.Username -> item.value = uri?.user is CredentialItem.Password -> { - item.value = cachedPassword?.clone() ?: passwordFinder.reqPassword(null).also { - cachedPassword = it.clone() - } + item.value = cachedPassword?.clone() + ?: passwordFinder.reqPassword(null).also { + cachedPassword = it.clone() + } } else -> UnsupportedCredentialItem(uri, item.javaClass.name) } @@ -88,8 +97,8 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: Fragment return this } - private fun withPublicKeyAuthentication(passphraseFinder: InteractivePasswordFinder): GitOperation { - val sessionFactory = SshjSessionFactory(SshAuthData.PublicKeyFile(sshKeyFile, passphraseFinder), hostKeyFile) + private fun withSshKeyAuthentication(passphraseFinder: InteractivePasswordFinder): GitOperation { + val sessionFactory = SshjSessionFactory(SshAuthData.SshKey(passphraseFinder), hostKeyFile) SshSessionFactory.setInstance(sessionFactory) this.provider = null return this @@ -126,27 +135,58 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: Fragment */ abstract suspend fun execute() + private fun onMissingSshKeyFile() { + MaterialAlertDialogBuilder(callingActivity) + .setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text)) + .setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title)) + .setPositiveButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_import)) { _, _ -> + getSshKey(false) + } + .setNegativeButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate)) { _, _ -> + getSshKey(true) + } + .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ -> + // Finish the blank GitActivity so user doesn't have to press back + callingActivity.finish() + }.show() + } + suspend fun executeAfterAuthentication( authMode: AuthMode, ) { when (authMode) { - AuthMode.SshKey -> if (!sshKeyFile.exists()) { - MaterialAlertDialogBuilder(callingActivity) - .setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text)) - .setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title)) - .setPositiveButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_import)) { _, _ -> - getSshKey(false) + AuthMode.SshKey -> if (SshKey.exists) { + if (SshKey.mustAuthenticate) { + val result = withContext(Dispatchers.Main) { + suspendCoroutine { cont -> + BiometricAuthenticator.authenticate(callingActivity, R.string.biometric_prompt_title_ssh_auth) { + if (it !is BiometricAuthenticator.Result.Failure) + cont.resume(it) + } + } } - .setNegativeButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate)) { _, _ -> - getSshKey(true) + when (result) { + is BiometricAuthenticator.Result.Success -> { + withSshKeyAuthentication(CredentialFinder(callingActivity, authMode)).execute() + } + is BiometricAuthenticator.Result.Cancelled -> callingActivity.finish() + is BiometricAuthenticator.Result.Failure -> { + throw IllegalStateException("Biometric authentication failures should be ignored") + } + else -> { + // There is a chance we succeed if the user recently confirmed + // their screen lock. Doing so would have a potential to confuse + // users though, who might deduce that the screen lock + // protection is not effective. Hence, we fail with an error. + Toast.makeText(callingActivity.applicationContext, R.string.biometric_auth_generic_failure, Toast.LENGTH_LONG).show() + callingActivity.finish() + } } - .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ -> - // Finish the blank GitActivity so user doesn't have to press back - callingActivity.finish() - }.show() + } else { + withSshKeyAuthentication(CredentialFinder(callingActivity, authMode)).execute() + } } else { - withPublicKeyAuthentication( - CredentialFinder(callingActivity, authMode)).execute() + onMissingSshKeyFile() } AuthMode.OpenKeychain -> withOpenKeychainAuthentication(callingActivity).execute() AuthMode.Password -> withPasswordAuthentication( diff --git a/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainKeyProvider.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainKeyProvider.kt index cecf7505..5cb1b006 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainKeyProvider.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainKeyProvider.kt @@ -22,8 +22,6 @@ import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import net.schmizz.sshj.common.Base64 -import net.schmizz.sshj.common.Buffer import net.schmizz.sshj.common.DisconnectReason import net.schmizz.sshj.common.KeyType import net.schmizz.sshj.userauth.UserAuthException @@ -46,7 +44,7 @@ class OpenKeychainKeyProvider private constructor(private val activity: Fragment companion object { suspend fun prepareAndUse(activity: FragmentActivity, block: (provider: OpenKeychainKeyProvider) -> Unit) { - withContext(Dispatchers.Main){ + withContext(Dispatchers.Main) { OpenKeychainKeyProvider(activity) }.prepareAndUse(block) } @@ -118,10 +116,8 @@ class OpenKeychainKeyProvider private constructor(private val activity: Fragment is ApiResponse.Success -> { val response = sshPublicKeyResponse.response as SshPublicKeyResponse val sshPublicKey = response.sshPublicKey!! - val sshKeyParts = sshPublicKey.split("""\s+""".toRegex()) - check(sshKeyParts.size >= 2) { "OpenKeychain API returned invalid SSH key" } - @Suppress("BlockingMethodInNonBlockingContext") - publicKey = Buffer.PlainBuffer(Base64.decode(sshKeyParts[1])).readPublicKey() + publicKey = parseSshPublicKey(sshPublicKey) + ?: throw IllegalStateException("OpenKeychain API returned invalid SSH key") } is ApiResponse.NoSuchKey -> if (isRetry) { throw sshPublicKeyResponse.exception diff --git a/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshKey.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshKey.kt new file mode 100644 index 00000000..8b657040 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshKey.kt @@ -0,0 +1,321 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.git.sshj + +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.KeyGenParameterSpec +import android.security.keystore.KeyInfo +import android.security.keystore.KeyProperties +import android.util.Base64 +import androidx.core.content.edit +import androidx.security.crypto.EncryptedFile +import androidx.security.crypto.MasterKey +import com.github.ajalt.timberkt.d +import com.github.ajalt.timberkt.e +import com.zeapo.pwdstore.Application +import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.utils.PreferenceKeys +import com.zeapo.pwdstore.utils.getEncryptedPrefs +import com.zeapo.pwdstore.utils.getString +import com.zeapo.pwdstore.utils.sharedPrefs +import java.io.File +import java.io.IOException +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.security.PublicKey +import javax.crypto.SecretKey +import javax.crypto.SecretKeyFactory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import net.i2p.crypto.eddsa.EdDSAPrivateKey +import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable +import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec +import net.schmizz.sshj.SSHClient +import net.schmizz.sshj.common.Buffer +import net.schmizz.sshj.common.KeyType +import net.schmizz.sshj.userauth.keyprovider.KeyProvider + +private const val PROVIDER_ANDROID_KEY_STORE = "AndroidKeyStore" +private const val KEYSTORE_ALIAS = "sshkey" +private const val ANDROIDX_SECURITY_KEYSET_PREF_NAME = "androidx_sshkey_keyset_prefs" + +private val androidKeystore: KeyStore by lazy { + KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE).apply { load(null) } +} + +private val KeyStore.sshPrivateKey + get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey + +private val KeyStore.sshPublicKey + get() = getCertificate(KEYSTORE_ALIAS)?.publicKey + +fun parseSshPublicKey(sshPublicKey: String): PublicKey? { + val sshKeyParts = sshPublicKey.split("""\s+""".toRegex()) + if (sshKeyParts.size < 2) + return null + return Buffer.PlainBuffer(Base64.decode(sshKeyParts[1], Base64.NO_WRAP)).readPublicKey() +} + +fun toSshPublicKey(publicKey: PublicKey): String { + val rawPublicKey = Buffer.PlainBuffer().putPublicKey(publicKey).compactData + val keyType = KeyType.fromKey(publicKey) + return "$keyType ${Base64.encodeToString(rawPublicKey, Base64.NO_WRAP)}" +} + +object SshKey { + + val sshPublicKey + get() = if (publicKeyFile.exists()) publicKeyFile.readText() else null + val canShowSshPublicKey + get() = type in listOf(Type.KeystoreNative, Type.KeystoreWrappedEd25519) + val exists + get() = type != null + val mustAuthenticate: Boolean + get() { + return try { + if (type !in listOf(Type.KeystoreNative, Type.KeystoreWrappedEd25519)) + return false + when (val key = androidKeystore.getKey(KEYSTORE_ALIAS, null)) { + is PrivateKey -> { + val factory = KeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE) + return 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 IllegalStateException("SSH key does not exist in Keystore") + } + } catch (error: Exception) { + // 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. + d(error) + false + } + } + + private val context: Context + get() = Application.instance.applicationContext + + private val privateKeyFile + get() = File(context.filesDir, ".ssh_key") + private val publicKeyFile + get() = File(context.filesDir, ".ssh_key.pub") + + private var type: Type? + get() = Type.fromValue(context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_KEY_TYPE)) + set(value) = context.sharedPrefs.edit { + putString(PreferenceKeys.GIT_REMOTE_KEY_TYPE, value?.value) + } + + private val isStrongBoxSupported by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE) + else + false + } + + private enum class Type(val value: String) { + Imported("imported"), + KeystoreNative("keystore_native"), + KeystoreWrappedEd25519("keystore_wrapped_ed25519"), + ; + + companion object { + + fun fromValue(value: String?): Type? = values().associateBy { it.value }[value] + } + } + + enum class Algorithm(val algorithm: String, val applyToSpec: KeyGenParameterSpec.Builder.() -> Unit) { + Rsa(KeyProperties.KEY_ALGORITHM_RSA, { + setKeySize(3072) + setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1) + setDigests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) + }), + Ecdsa(KeyProperties.KEY_ALGORITHM_EC, { + setKeySize(256) + setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1")) + setDigests(KeyProperties.DIGEST_SHA256) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + setIsStrongBoxBacked(isStrongBoxSupported) + } + }), + } + + private fun delete() { + androidKeystore.deleteEntry(KEYSTORE_ALIAS) + // Remove Tink key set used by AndroidX's EncryptedFile. + context.getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE).edit { + clear() + } + if (privateKeyFile.isFile) { + privateKeyFile.delete() + } + if (publicKeyFile.isFile) { + publicKeyFile.delete() + } + context.getEncryptedPrefs("git_operation").edit { + remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) + } + type = null + } + + fun import(uri: Uri) { + // First check whether the content at uri is likely an SSH private key. + val fileSize = context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null) + ?.use { cursor -> + // Cursor returns only a single row. + cursor.moveToFirst() + cursor.getInt(0) + } ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist)) + + // We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes. + if (fileSize > 100_000 || fileSize == 0) + throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message)) + + val sshKeyInputStream = context.contentResolver.openInputStream(uri) + ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist)) + val lines = sshKeyInputStream.bufferedReader().readLines() + + // The file must have more than 2 lines, and the first and last line must have private key + // markers. + if (lines.size < 2 || + !Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) || + !Regex("END .* PRIVATE KEY").containsMatchIn(lines.last()) + ) + throw IllegalArgumentException(context.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. + delete() + // Canonicalize line endings to '\n'. + privateKeyFile.writeText(lines.joinToString("\n")) + + type = Type.Imported + } + + @Suppress("BlockingMethodInNonBlockingContext") + private suspend fun getOrCreateWrappingMasterKey(requireAuthentication: Boolean) = withContext(Dispatchers.IO) { + MasterKey.Builder(context, KEYSTORE_ALIAS).run { + setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + setRequestStrongBoxBacked(true) + setUserAuthenticationRequired(requireAuthentication, 15) + build() + } + } + + @Suppress("BlockingMethodInNonBlockingContext") + private suspend fun getOrCreateWrappedPrivateKeyFile(requireAuthentication: Boolean) = withContext(Dispatchers.IO) { + EncryptedFile.Builder(context, + privateKeyFile, + getOrCreateWrappingMasterKey(requireAuthentication), + EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB).run { + setKeysetPrefName(ANDROIDX_SECURITY_KEYSET_PREF_NAME) + build() + } + } + + @Suppress("BlockingMethodInNonBlockingContext") + suspend fun generateKeystoreWrappedEd25519Key(requireAuthentication: Boolean) = withContext(Dispatchers.IO) { + delete() + + val encryptedPrivateKeyFile = getOrCreateWrappedPrivateKeyFile(requireAuthentication) + // Generate the ed25519 key pair and encrypt the private key. + val keyPair = net.i2p.crypto.eddsa.KeyPairGenerator().generateKeyPair() + encryptedPrivateKeyFile.openFileOutput().use { os -> + os.write((keyPair.private as EdDSAPrivateKey).seed) + } + + // Write public key in SSH format to .ssh_key.pub. + publicKeyFile.writeText(toSshPublicKey(keyPair.public)) + + type = Type.KeystoreWrappedEd25519 + } + + fun generateKeystoreNativeKey(algorithm: Algorithm, requireAuthentication: Boolean) { + delete() + + // Generate Keystore-backed private key. + val parameterSpec = KeyGenParameterSpec.Builder( + KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN + ).run { + apply(algorithm.applyToSpec) + if (requireAuthentication) { + setUserAuthenticationRequired(true) + setUserAuthenticationValidityDurationSeconds(30) + } + build() + } + val keyPair = KeyPairGenerator.getInstance(algorithm.algorithm, PROVIDER_ANDROID_KEY_STORE).run { + initialize(parameterSpec) + generateKeyPair() + } + + // Write public key in SSH format to .ssh_key.pub. + publicKeyFile.writeText(toSshPublicKey(keyPair.public)) + + type = Type.KeystoreNative + } + + fun provide(client: SSHClient, passphraseFinder: InteractivePasswordFinder): KeyProvider? = when (type) { + Type.Imported -> client.loadKeys(privateKeyFile.absolutePath, passphraseFinder) + Type.KeystoreNative -> KeystoreNativeKeyProvider + Type.KeystoreWrappedEd25519 -> KeystoreWrappedEd25519KeyProvider + null -> null + } + + private object KeystoreNativeKeyProvider : KeyProvider { + + override fun getPublic(): PublicKey = try { + androidKeystore.sshPublicKey!! + } catch (error: Throwable) { + e(error) + throw IOException("Failed to get public key '$KEYSTORE_ALIAS' from Android Keystore", error) + } + + override fun getPrivate(): PrivateKey = try { + androidKeystore.sshPrivateKey!! + } catch (error: Throwable) { + e(error) + throw IOException("Failed to access private key '$KEYSTORE_ALIAS' from Android Keystore", error) + } + + override fun getType(): KeyType = KeyType.fromKey(public) + } + + private object KeystoreWrappedEd25519KeyProvider : KeyProvider { + + override fun getPublic(): PublicKey = try { + parseSshPublicKey(sshPublicKey!!)!! + } catch (error: Throwable) { + e(error) + throw IOException("Failed to get the public key for wrapped ed25519 key", error) + } + + override fun getPrivate(): PrivateKey = try { + // 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(false) + } + val rawPrivateKey = encryptedPrivateKeyFile.openFileInput().use { it.readBytes() } + EdDSAPrivateKey(EdDSAPrivateKeySpec(rawPrivateKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC)) + } catch (error: Throwable) { + e(error) + throw IOException("Failed to unwrap wrapped ed25519 key", error) + } + + override fun getType(): KeyType = KeyType.fromKey(public) + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt index bf454cd5..0cd5459b 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt @@ -15,6 +15,7 @@ import java.security.Security import net.schmizz.keepalive.KeepAliveProvider import net.schmizz.sshj.ConfigImpl import net.schmizz.sshj.common.LoggerFactory +import net.schmizz.sshj.common.SecurityUtils import net.schmizz.sshj.transport.compression.NoneCompression import net.schmizz.sshj.transport.kex.Curve25519SHA256 import net.schmizz.sshj.transport.kex.Curve25519SHA256.FactoryLibSsh @@ -52,6 +53,9 @@ fun setUpBouncyCastleForSshj() { Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1) } d { "JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}" } + // Prevent sshj from forwarding all cryptographic operations to BC. + SecurityUtils.setRegisterBouncyCastle(false) + SecurityUtils.setSecurityProvider(null) } private abstract class AbstractLogger(private val name: String) : Logger { diff --git a/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjSessionFactory.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjSessionFactory.kt index 05428e41..58002af0 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjSessionFactory.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjSessionFactory.kt @@ -37,7 +37,7 @@ import org.eclipse.jgit.util.FS sealed class SshAuthData { class Password(val passwordFinder: InteractivePasswordFinder) : SshAuthData() - class PublicKeyFile(val keyFile: File, val passphraseFinder: InteractivePasswordFinder) : SshAuthData() + class SshKey(val passphraseFinder: InteractivePasswordFinder) : SshAuthData() class OpenKeychain(val activity: FragmentActivity) : SshAuthData() } @@ -127,8 +127,8 @@ private class SshjSession(uri: URIish, private val username: String, private val is SshAuthData.Password -> { ssh.authPassword(username, authData.passwordFinder) } - is SshAuthData.PublicKeyFile -> { - ssh.authPublickey(username, ssh.loadKeys(authData.keyFile.absolutePath, authData.passphraseFinder)) + is SshAuthData.SshKey -> { + ssh.authPublickey(username, SshKey.provide(ssh, authData.passphraseFinder)) } is SshAuthData.OpenKeychain -> { runBlocking { diff --git a/app/src/main/java/com/zeapo/pwdstore/sshkeygen/ShowSshKeyFragment.kt b/app/src/main/java/com/zeapo/pwdstore/sshkeygen/ShowSshKeyFragment.kt index 109ebd01..bfa7e1c8 100644 --- a/app/src/main/java/com/zeapo/pwdstore/sshkeygen/ShowSshKeyFragment.kt +++ b/app/src/main/java/com/zeapo/pwdstore/sshkeygen/ShowSshKeyFragment.kt @@ -4,61 +4,35 @@ */ package com.zeapo.pwdstore.sshkeygen -import android.annotation.SuppressLint import android.app.Dialog -import android.content.ClipData +import android.content.Intent import android.os.Bundle -import android.view.View -import android.widget.TextView -import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.utils.clipboard -import java.io.File +import com.zeapo.pwdstore.git.sshj.SshKey class ShowSshKeyFragment : DialogFragment() { - private lateinit var builder: MaterialAlertDialogBuilder - private lateinit var publicKey: TextView - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - builder = MaterialAlertDialogBuilder(requireActivity()) - } - - @SuppressLint("InflateParams") override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val activity = requireActivity() - val view = activity.layoutInflater.inflate(R.layout.fragment_show_ssh_key, null) - publicKey = view.findViewById(R.id.public_key) - readKeyFromFile() - createMaterialDialog(view) - val ad = builder.create() - ad.setOnShowListener { - val b = ad.getButton(AlertDialog.BUTTON_POSITIVE) - b.setOnClickListener { - val clipboard = activity.clipboard ?: return@setOnClickListener - val clip = ClipData.newPlainText("public key", publicKey.text.toString()) - clipboard.setPrimaryClip(clip) + val publicKey = SshKey.sshPublicKey + return MaterialAlertDialogBuilder(requireActivity()).run { + setMessage(getString(R.string.ssh_keygen_message, publicKey)) + setTitle(R.string.your_public_key) + setNegativeButton(R.string.ssh_keygen_later) { _, _ -> + (activity as? SshKeyGenActivity)?.finish() } - } - return ad - } - - private fun createMaterialDialog(view: View) { - builder.setView(view) - builder.setTitle(getString(R.string.your_public_key)) - builder.setNegativeButton(R.string.dialog_ok) { _, _ -> requireActivity().finish() } - builder.setPositiveButton(R.string.ssh_keygen_copy, null) - } - - private fun readKeyFromFile() { - val file = File(requireActivity().filesDir.toString() + "/.ssh_key.pub") - try { - publicKey.text = file.readText() - } catch (e: Exception) { - e.printStackTrace() + setPositiveButton(R.string.ssh_keygen_share) { _, _ -> + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, publicKey) + } + startActivity(Intent.createChooser(sendIntent, null)) + (activity as? SshKeyGenActivity)?.finish() + } + create() } } } diff --git a/app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenActivity.kt b/app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenActivity.kt index 98bda9d7..810a8925 100644 --- a/app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenActivity.kt @@ -5,6 +5,7 @@ package com.zeapo.pwdstore.sshkeygen import android.os.Bundle +import android.security.keystore.UserNotAuthenticatedException import android.view.MenuItem import android.view.View import android.view.inputmethod.InputMethodManager @@ -13,22 +14,34 @@ import androidx.core.content.edit import androidx.core.content.getSystemService import androidx.lifecycle.lifecycleScope import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.jcraft.jsch.JSch -import com.jcraft.jsch.KeyPair import com.zeapo.pwdstore.R import com.zeapo.pwdstore.databinding.ActivitySshKeygenBinding +import com.zeapo.pwdstore.git.sshj.SshKey +import com.zeapo.pwdstore.utils.BiometricAuthenticator import com.zeapo.pwdstore.utils.getEncryptedPrefs -import com.zeapo.pwdstore.utils.sharedPrefs +import com.zeapo.pwdstore.utils.keyguardManager import com.zeapo.pwdstore.utils.viewBinding -import java.io.File -import java.io.FileOutputStream +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine 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) + }), +} + class SshKeyGenActivity : AppCompatActivity() { - private var keyLength = 4096 + private var keyGenType = KeyGenType.Ecdsa private val binding by viewBinding(ActivitySshKeygenBinding::inflate) override fun onCreate(savedInstanceState: Bundle?) { @@ -37,17 +50,45 @@ class SshKeyGenActivity : AppCompatActivity() { supportActionBar?.setDisplayHomeAsUpEnabled(true) with(binding) { generate.setOnClickListener { - lifecycleScope.launch { generate(passphrase.text.toString(), comment.text.toString()) } + if (SshKey.exists) { + MaterialAlertDialogBuilder(this@SshKeyGenActivity).run { + setTitle(R.string.ssh_keygen_existing_title) + setMessage(R.string.ssh_keygen_existing_message) + setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> + lifecycleScope.launch { + generate() + } + } + setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> + finish() + } + show() + } + } else { + lifecycleScope.launch { + generate() + } + } } - keyLengthGroup.check(R.id.key_length_4096) - keyLengthGroup.addOnButtonCheckedListener { _, checkedId, isChecked -> + keyTypeGroup.check(R.id.key_type_ecdsa) + keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa) + keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked -> if (isChecked) { - when (checkedId) { - R.id.key_length_2048 -> keyLength = 2048 - R.id.key_length_4096 -> keyLength = 4096 + keyGenType = when (checkedId) { + R.id.key_type_ed25519 -> KeyGenType.Ed25519 + R.id.key_type_ecdsa -> KeyGenType.Ecdsa + R.id.key_type_rsa -> KeyGenType.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 + }) } } + keyRequireAuthentication.isEnabled = keyguardManager.isDeviceSecure + keyRequireAuthentication.isChecked = keyRequireAuthentication.isEnabled } } @@ -62,21 +103,27 @@ class SshKeyGenActivity : AppCompatActivity() { } } - private suspend fun generate(passphrase: String, comment: String) { + private suspend fun generate() { + binding.generate.apply { + text = getString(R.string.ssh_key_gen_generating_progress) + isEnabled = false + } binding.generate.text = getString(R.string.ssh_key_gen_generating_progress) val e = try { withContext(Dispatchers.IO) { - val kp = KeyPair.genKeyPair(JSch(), KeyPair.RSA, keyLength) - var file = File(filesDir, ".ssh_key") - var out = FileOutputStream(file, false) - if (passphrase.isNotEmpty()) { - kp?.writePrivateKey(out, passphrase.toByteArray()) - } else { - kp?.writePrivateKey(out) + val requireAuthentication = binding.keyRequireAuthentication.isChecked + if (requireAuthentication) { + val result = withContext(Dispatchers.Main) { + suspendCoroutine { cont -> + BiometricAuthenticator.authenticate(this@SshKeyGenActivity, R.string.biometric_prompt_title_ssh_keygen) { + cont.resume(it) + } + } + } + if (result !is BiometricAuthenticator.Result.Success) + throw UserNotAuthenticatedException(getString(R.string.biometric_auth_generic_failure)) } - file = File(filesDir, ".ssh_key.pub") - out = FileOutputStream(file, false) - kp?.writePublicKey(out, comment) + keyGenType.generateKey(requireAuthentication) } null } catch (e: Exception) { @@ -87,11 +134,13 @@ class SshKeyGenActivity : AppCompatActivity() { remove("ssh_key_local_passphrase") } } - binding.generate.text = getString(R.string.ssh_keygen_generating_done) + binding.generate.apply { + text = getString(R.string.ssh_keygen_generate) + isEnabled = true + } if (e == null) { val df = ShowSshKeyFragment() df.show(supportFragmentManager, "public_key") - sharedPrefs.edit { putBoolean("use_generated_key", true) } } else { MaterialAlertDialogBuilder(this) .setTitle(getString(R.string.error_generate_ssh_key)) diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/BiometricAuthenticator.kt b/app/src/main/java/com/zeapo/pwdstore/utils/BiometricAuthenticator.kt index 0792c1fc..d7ecb4cd 100644 --- a/app/src/main/java/com/zeapo/pwdstore/utils/BiometricAuthenticator.kt +++ b/app/src/main/java/com/zeapo/pwdstore/utils/BiometricAuthenticator.kt @@ -5,12 +5,12 @@ package com.zeapo.pwdstore.utils import android.app.KeyguardManager -import android.os.Handler import androidx.annotation.StringRes import androidx.biometric.BiometricConstants import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager.Authenticators import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.fragment.app.FragmentActivity import com.github.ajalt.timberkt.Timber.tag @@ -20,7 +20,6 @@ import com.zeapo.pwdstore.R object BiometricAuthenticator { private const val TAG = "BiometricAuthenticator" - private val handler = Handler() sealed class Result { data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result() @@ -69,7 +68,7 @@ object BiometricAuthenticator { .setTitle(activity.getString(dialogTitleRes)) .setAllowedAuthenticators(validAuthenticators) .build() - BiometricPrompt(activity, { handler.post(it) }, authCallback).authenticate(promptInfo) + BiometricPrompt(activity, ContextCompat.getMainExecutor(activity.applicationContext), authCallback).authenticate(promptInfo) } else { callback(Result.HardwareUnavailableOrDisabled) } diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt b/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt index edc01776..f41899b9 100644 --- a/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt +++ b/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt @@ -4,6 +4,7 @@ */ package com.zeapo.pwdstore.utils +import android.app.KeyguardManager import android.content.ClipboardManager import android.content.Context import android.content.Intent @@ -162,6 +163,9 @@ val Context.autofillManager: AutofillManager? @RequiresApi(Build.VERSION_CODES.O) get() = getSystemService() +val Context.keyguardManager: KeyguardManager + get() = getSystemService()!! + fun File.isInsideRepository(): Boolean { return canonicalPath.contains(getRepositoryDirectory().canonicalPath) } diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/PreferenceKeys.kt b/app/src/main/java/com/zeapo/pwdstore/utils/PreferenceKeys.kt index bcda4505..5925809c 100644 --- a/app/src/main/java/com/zeapo/pwdstore/utils/PreferenceKeys.kt +++ b/app/src/main/java/com/zeapo/pwdstore/utils/PreferenceKeys.kt @@ -28,6 +28,7 @@ object PreferenceKeys { const val GIT_EXTERNAL = "git_external" const val GIT_EXTERNAL_REPO = "git_external_repo" const val GIT_REMOTE_AUTH = "git_remote_auth" + const val GIT_REMOTE_KEY_TYPE = "git_remote_key_type" @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_LOCATION = "git_remote_location" @@ -75,6 +76,4 @@ object PreferenceKeys { const val SSH_OPENKEYSTORE_CLEAR_KEY_ID = "ssh_openkeystore_clear_keyid" const val SSH_OPENKEYSTORE_KEYID = "ssh_openkeystore_keyid" const val SSH_SEE_KEY = "ssh_see_key" - const val USE_GENERATED_KEY = "use_generated_key" - } diff --git a/app/src/main/res/layout/activity_ssh_keygen.xml b/app/src/main/res/layout/activity_ssh_keygen.xml index 6ec3f2fb..17180f06 100644 --- a/app/src/main/res/layout/activity_ssh_keygen.xml +++ b/app/src/main/res/layout/activity_ssh_keygen.xml @@ -13,16 +13,11 @@ android:layout_height="wrap_content" android:orientation="vertical" android:paddingLeft="@dimen/activity_horizontal_margin" + android:paddingTop="@dimen/activity_vertical_margin" android:paddingRight="@dimen/activity_horizontal_margin"> - - + + + android:text="@string/ssh_keygen_label_ecdsa" /> + android:text="@string/ssh_keygen_label_ed25519" /> - - - - + android:paddingTop="8dp" /> - - - - + android:text="@string/ssh_keygen_require_authentication" /> diff --git a/app/src/main/res/layout/fragment_show_ssh_key.xml b/app/src/main/res/layout/fragment_show_ssh_key.xml deleted file mode 100644 index a933759e..00000000 --- a/app/src/main/res/layout/fragment_show_ssh_key.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/layout/git_credential_layout.xml b/app/src/main/res/layout/git_credential_layout.xml index 180b904a..3e520bf1 100644 --- a/app/src/main/res/layout/git_credential_layout.xml +++ b/app/src/main/res/layout/git_credential_layout.xml @@ -14,8 +14,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content" app:endIconMode="password_toggle" - app:hintEnabled="true" app:errorEnabled="true" + app:hintEnabled="true" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"> diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 695a7499..1fd90cb2 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -87,7 +87,6 @@ العبارة السرية تعليق توليد - نسخ حسناً diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 3c4df560..b7769e53 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -130,8 +130,6 @@ Bezpečnostní fráze Komentář Generovat - Kopírovat - Přidat tento veřejný klíč na Git server. OK diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d5abea1a..1c89081a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -111,8 +111,6 @@ Passwort Kommentar Generieren - Kopieren - Füge den Public-Key zu deinem Git-Server hinzu. OK diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 03e153e9..c781f7aa 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -135,8 +135,6 @@ Contraseña Comentario Generar - Copiar - Registra esta llave pública en tu servidor Git. OK diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index db98bfd8..e97fd1fb 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -135,8 +135,6 @@ Mot de passe Commentaire Générer - Copier - Enregistrez cette clef publique sur votre serveur Git. OK diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 132ecad4..65f378a9 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -94,8 +94,6 @@ パスフレーズ コメント 生成 - コピー - この公開鍵を Git サーバーに提供してください。 OK diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 2e07190a..535267c7 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -169,8 +169,6 @@ Frase Secreta Comentário Gerar - Copiar - Forneça esta chave pública para seu servidor Git. Gerando chaves… Concluído! diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d1fbc215..291429bc 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -164,8 +164,6 @@ Пароль Комментарий Сгенерировать - Скоприровать - Поместите публичный ключ на сервер Git OK diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e7899c76..ebc710d4 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -91,8 +91,6 @@ 口令 备注 生成 - 复制 - 在你的Git服务器上提供此公钥 确定 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 572d67da..b380c560 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -88,8 +88,6 @@ 密碼 備註 產生 - 複製 - 在你的 Git 伺服器上提供此公鑰 確定 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 72d4d5db..5c8b75cd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -199,12 +199,29 @@ Passphrase Comment Generate - Copy - Provide this public key to your Git server. + Share + Later + %1$s\n\nProvide this public key to your Git server. Generating keys… Done! - 2048 - 4096 + Protect with screen lock credential + Public key copied to clipboard + RSA + ECDSA + Ed25519 + RSA (3072 bit)\nSupported by all servers, but authentication is comparatively slow. + ECDSA (NIST P-256)\nFast authentication and supported by most servers that are still receiving updates. + Ed25519\nFast authentication, but only supported by rather modern servers. + SSH key + Replace existing SSH key? You might lose access to your server. + Replace + Keep + + + + Screen lock authentication failed + Unlock SSH key + Generate SSH key OK -- cgit v1.2.3