aboutsummaryrefslogtreecommitdiff
path: root/app/src/main/java
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java')
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/Application.kt5
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/ClipboardService.kt3
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/Migrations.kt4
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt1
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/UserPreference.kt82
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt1
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/ErrorMessages.kt2
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt2
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt80
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainKeyProvider.kt10
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/sshj/SshKey.kt321
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt4
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjSessionFactory.kt6
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/sshkeygen/ShowSshKeyFragment.kt62
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenActivity.kt99
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/utils/BiometricAuthenticator.kt5
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt4
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/utils/PreferenceKeys.kt3
18 files changed, 533 insertions, 161 deletions
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<Preference>
private lateinit var oreoAutofillDependencies: List<Preference>
private lateinit var prefsActivity: UserPreference
@@ -89,8 +89,8 @@ class UserPreference : AppCompatActivity() {
val gitConfigPreference = findPreference<Preference>(PreferenceKeys.GIT_CONFIG)
val sshKeyPreference = findPreference<Preference>(PreferenceKeys.SSH_KEY)
val sshKeygenPreference = findPreference<Preference>(PreferenceKeys.SSH_KEYGEN)
+ viewSshKeyPreference = findPreference<Preference>(PreferenceKeys.SSH_SEE_KEY)
clearSavedPassPreference = findPreference(PreferenceKeys.CLEAR_SAVED_PASS)
- val viewSshKeyPreference = findPreference<Preference>(PreferenceKeys.SSH_SEE_KEY)
val deleteRepoPreference = findPreference<Preference>(PreferenceKeys.GIT_DELETE_REPO)
val externalGitRepositoryPreference = findPreference<Preference>(PreferenceKeys.GIT_EXTERNAL)
val selectExternalGitRepositoryPreference = findPreference<Preference>(PreferenceKeys.PREF_SELECT_EXTERNAL)
@@ -141,8 +141,8 @@ class UserPreference : AppCompatActivity() {
// Misc preferences
val appVersionPreference = findPreference<Preference>(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)
@@ -567,6 +562,25 @@ class UserPreference : AppCompatActivity() {
}
/**
+ * 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
*/
private fun exportPasswords() {
@@ -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<AccessibilityManager>() ?: 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<GitCommand<out Any>>
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<BiometricAuthenticator.Result> { 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<BiometricAuthenticator.Result> { 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"
-
}