Add Keystore backend for SSH public key authentication (#1070)

This commit is contained in:
Fabian Henneke 2020-09-01 10:12:27 +02:00 committed by GitHub
parent 55d64fb737
commit cbb96397d1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 583 additions and 258 deletions

View file

@ -9,12 +9,15 @@ All notable changes to this project will be documented in this file.
- Allow sorting by recently used - Allow sorting by recently used
- Add [Bromite](https://www.bromite.org/) and [Ungoogled Chromium](https://git.droidware.info/wchen342/ungoogled-chromium-android) to supported browsers list for Autofill - Add [Bromite](https://www.bromite.org/) and [Ungoogled Chromium](https://git.droidware.info/wchen342/ungoogled-chromium-android) to supported browsers list for Autofill
- Add ability to view the Git commit log - Add ability to view the Git commit log
- Allow generating ECDSA and ED25519 keys for SSH
### Changed ### Changed
- A descriptive error message is shown if no username is specified in the Git server settings - A descriptive error message is shown if no username is specified in the Git server settings
- Remove explicit protocol choice from Git server settings, it is now inferred from your URL - Remove explicit protocol choice from Git server settings, it is now inferred from your URL
- 'Show hidden folders' is now 'Show hidden files and folders' - 'Show hidden folders' is now 'Show hidden files and folders'
- Generated SSH keys are now stored in the Android Keystore if available, and encrypted at rest otherwise
- Allow using device's screen lock credentials to secure generated SSH key
### Fixed ### Fixed

View file

@ -2,8 +2,8 @@
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only * SPDX-License-Identifier: GPL-3.0-only
*/ */
import java.util.Properties
import com.android.build.gradle.internal.api.BaseVariantOutputImpl import com.android.build.gradle.internal.api.BaseVariantOutputImpl
import java.util.Properties
plugins { plugins {
kotlin("android") kotlin("android")
@ -113,11 +113,11 @@ dependencies {
implementation(Dependencies.FirstParty.zxing_android_embedded) implementation(Dependencies.FirstParty.zxing_android_embedded)
implementation(Dependencies.ThirdParty.commons_codec) implementation(Dependencies.ThirdParty.commons_codec)
implementation(Dependencies.ThirdParty.eddsa)
implementation(Dependencies.ThirdParty.fastscroll) implementation(Dependencies.ThirdParty.fastscroll)
implementation(Dependencies.ThirdParty.jgit) { implementation(Dependencies.ThirdParty.jgit) {
exclude(group = "org.apache.httpcomponents", module = "httpclient") exclude(group = "org.apache.httpcomponents", module = "httpclient")
} }
implementation(Dependencies.ThirdParty.jsch)
implementation(Dependencies.ThirdParty.sshj) implementation(Dependencies.ThirdParty.sshj)
implementation(Dependencies.ThirdParty.bouncycastle) implementation(Dependencies.ThirdParty.bouncycastle)
implementation(Dependencies.ThirdParty.plumber) implementation(Dependencies.ThirdParty.plumber)

View file

@ -18,7 +18,6 @@
-keepattributes SourceFile,LineNumberTable -keepattributes SourceFile,LineNumberTable
-dontobfuscate -dontobfuscate
-keep class com.jcraft.jsch.**
-keep class org.eclipse.jgit.internal.JGitText { *; } -keep class org.eclipse.jgit.internal.JGitText { *; }
-keep class org.bouncycastle.jcajce.provider.** { *; } -keep class org.bouncycastle.jcajce.provider.** { *; }
-keep class org.bouncycastle.jce.provider.** { *; } -keep class org.bouncycastle.jce.provider.** { *; }

View file

@ -14,8 +14,8 @@ import com.github.ajalt.timberkt.Timber.DebugTree
import com.github.ajalt.timberkt.Timber.plant import com.github.ajalt.timberkt.Timber.plant
import com.zeapo.pwdstore.git.sshj.setUpBouncyCastleForSshj import com.zeapo.pwdstore.git.sshj.setUpBouncyCastleForSshj
import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.PreferenceKeys
import com.zeapo.pwdstore.utils.sharedPrefs
import com.zeapo.pwdstore.utils.getString import com.zeapo.pwdstore.utils.getString
import com.zeapo.pwdstore.utils.sharedPrefs
@Suppress("Unused") @Suppress("Unused")
class Application : android.app.Application(), SharedPreferences.OnSharedPreferenceChangeListener { class Application : android.app.Application(), SharedPreferences.OnSharedPreferenceChangeListener {
@ -45,7 +45,8 @@ class Application : android.app.Application(), SharedPreferences.OnSharedPrefere
} }
private fun setNightMode() { 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 "light" -> MODE_NIGHT_NO
"dark" -> MODE_NIGHT_YES "dark" -> MODE_NIGHT_YES
"follow_system" -> MODE_NIGHT_FOLLOW_SYSTEM "follow_system" -> MODE_NIGHT_FOLLOW_SYSTEM

View file

@ -46,7 +46,8 @@ class ClipboardService : Service() {
ACTION_START -> { ACTION_START -> {
val time = try { val time = try {
Integer.parseInt(settings.getString(PreferenceKeys.GENERAL_SHOW_TIME) ?: "45") Integer.parseInt(settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)
?: "45")
} catch (e: NumberFormatException) { } catch (e: NumberFormatException) {
45 45
} }

View file

@ -45,8 +45,8 @@ private fun migrateToGitUrlBasedConfig(context: Context) {
if (!serverPath.startsWith('/')) if (!serverPath.startsWith('/'))
null null
else else
// We have to specify the ssh scheme as this is the only way to pass a custom // We have to specify the ssh scheme as this is the only way to pass a custom
// port. // port.
"ssh://$userPart$hostnamePart$portPart$serverPath" "ssh://$userPart$hostnamePart$portPart$serverPath"
} }
} }

View file

@ -48,7 +48,6 @@ import com.zeapo.pwdstore.crypto.BasePgpActivity.Companion.getLongName
import com.zeapo.pwdstore.crypto.DecryptActivity import com.zeapo.pwdstore.crypto.DecryptActivity
import com.zeapo.pwdstore.crypto.PasswordCreationActivity import com.zeapo.pwdstore.crypto.PasswordCreationActivity
import com.zeapo.pwdstore.git.BaseGitActivity import com.zeapo.pwdstore.git.BaseGitActivity
import com.zeapo.pwdstore.git.log.GitLogActivity
import com.zeapo.pwdstore.git.GitOperationActivity import com.zeapo.pwdstore.git.GitOperationActivity
import com.zeapo.pwdstore.git.GitServerConfigActivity import com.zeapo.pwdstore.git.GitServerConfigActivity
import com.zeapo.pwdstore.git.config.AuthMode import com.zeapo.pwdstore.git.config.AuthMode

View file

@ -15,7 +15,6 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Environment import android.os.Environment
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.provider.OpenableColumns
import android.provider.Settings import android.provider.Settings
import android.text.TextUtils import android.text.TextUtils
import android.view.MenuItem 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.crypto.BasePgpActivity
import com.zeapo.pwdstore.git.GitConfigActivity import com.zeapo.pwdstore.git.GitConfigActivity
import com.zeapo.pwdstore.git.GitServerConfigActivity import com.zeapo.pwdstore.git.GitServerConfigActivity
import com.zeapo.pwdstore.git.sshj.SshKey
import com.zeapo.pwdstore.pwgenxkpwd.XkpwdDictionary import com.zeapo.pwdstore.pwgenxkpwd.XkpwdDictionary
import com.zeapo.pwdstore.sshkeygen.ShowSshKeyFragment import com.zeapo.pwdstore.sshkeygen.ShowSshKeyFragment
import com.zeapo.pwdstore.sshkeygen.SshKeyGenActivity 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.getString
import com.zeapo.pwdstore.utils.sharedPrefs import com.zeapo.pwdstore.utils.sharedPrefs
import java.io.File import java.io.File
import java.io.IOException
typealias ClickListener = Preference.OnPreferenceClickListener typealias ClickListener = Preference.OnPreferenceClickListener
typealias ChangeListener = Preference.OnPreferenceChangeListener typealias ChangeListener = Preference.OnPreferenceChangeListener
@ -69,6 +68,7 @@ class UserPreference : AppCompatActivity() {
private var autoFillEnablePreference: SwitchPreferenceCompat? = null private var autoFillEnablePreference: SwitchPreferenceCompat? = null
private var clearSavedPassPreference: Preference? = null private var clearSavedPassPreference: Preference? = null
private var viewSshKeyPreference: Preference? = null
private lateinit var autofillDependencies: List<Preference> private lateinit var autofillDependencies: List<Preference>
private lateinit var oreoAutofillDependencies: List<Preference> private lateinit var oreoAutofillDependencies: List<Preference>
private lateinit var prefsActivity: UserPreference private lateinit var prefsActivity: UserPreference
@ -89,8 +89,8 @@ class UserPreference : AppCompatActivity() {
val gitConfigPreference = findPreference<Preference>(PreferenceKeys.GIT_CONFIG) val gitConfigPreference = findPreference<Preference>(PreferenceKeys.GIT_CONFIG)
val sshKeyPreference = findPreference<Preference>(PreferenceKeys.SSH_KEY) val sshKeyPreference = findPreference<Preference>(PreferenceKeys.SSH_KEY)
val sshKeygenPreference = findPreference<Preference>(PreferenceKeys.SSH_KEYGEN) val sshKeygenPreference = findPreference<Preference>(PreferenceKeys.SSH_KEYGEN)
viewSshKeyPreference = findPreference<Preference>(PreferenceKeys.SSH_SEE_KEY)
clearSavedPassPreference = findPreference(PreferenceKeys.CLEAR_SAVED_PASS) clearSavedPassPreference = findPreference(PreferenceKeys.CLEAR_SAVED_PASS)
val viewSshKeyPreference = findPreference<Preference>(PreferenceKeys.SSH_SEE_KEY)
val deleteRepoPreference = findPreference<Preference>(PreferenceKeys.GIT_DELETE_REPO) val deleteRepoPreference = findPreference<Preference>(PreferenceKeys.GIT_DELETE_REPO)
val externalGitRepositoryPreference = findPreference<Preference>(PreferenceKeys.GIT_EXTERNAL) val externalGitRepositoryPreference = findPreference<Preference>(PreferenceKeys.GIT_EXTERNAL)
val selectExternalGitRepositoryPreference = findPreference<Preference>(PreferenceKeys.PREF_SELECT_EXTERNAL) val selectExternalGitRepositoryPreference = findPreference<Preference>(PreferenceKeys.PREF_SELECT_EXTERNAL)
@ -141,8 +141,8 @@ class UserPreference : AppCompatActivity() {
// Misc preferences // Misc preferences
val appVersionPreference = findPreference<Preference>(PreferenceKeys.APP_VERSION) val appVersionPreference = findPreference<Preference>(PreferenceKeys.APP_VERSION)
selectExternalGitRepositoryPreference?.summary = sharedPreferences.getString(PreferenceKeys.GIT_EXTERNAL_REPO) ?: getString(R.string.no_repo_selected) selectExternalGitRepositoryPreference?.summary = sharedPreferences.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
viewSshKeyPreference?.isVisible = sharedPreferences.getBoolean(PreferenceKeys.USE_GENERATED_KEY, false) ?: getString(R.string.no_repo_selected)
deleteRepoPreference?.isVisible = !sharedPreferences.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) deleteRepoPreference?.isVisible = !sharedPreferences.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)
clearClipboard20xPreference?.isVisible = sharedPreferences.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toInt() != 0 clearClipboard20xPreference?.isVisible = sharedPreferences.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toInt() != 0
openkeystoreIdPreference?.isVisible = sharedPreferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty() openkeystoreIdPreference?.isVisible = sharedPreferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty()
@ -226,7 +226,8 @@ class UserPreference : AppCompatActivity() {
} }
selectExternalGitRepositoryPreference?.summary = 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 { selectExternalGitRepositoryPreference?.onPreferenceClickListener = ClickListener {
prefsActivity.selectExternalGitRepository() prefsActivity.selectExternalGitRepository()
true true
@ -393,6 +394,10 @@ class UserPreference : AppCompatActivity() {
} }
} }
private fun updateViewSshPubkeyPref() {
viewSshKeyPreference?.isVisible = SshKey.canShowSshPublicKey
}
private fun onEnableAutofillClick() { private fun onEnableAutofillClick() {
if (prefsActivity.isAccessibilityServiceEnabled) { if (prefsActivity.isAccessibilityServiceEnabled) {
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
@ -451,6 +456,7 @@ class UserPreference : AppCompatActivity() {
super.onResume() super.onResume()
updateAutofillSettings() updateAutofillSettings()
updateClearSavedPassphrasePrefs() updateClearSavedPassphrasePrefs()
updateViewSshPubkeyPref()
} }
} }
@ -532,29 +538,18 @@ class UserPreference : AppCompatActivity() {
} }
} }
/** private fun importSshKey() {
* Opens a file explorer to import the private key
*/
private fun getSshKey() {
registerForActivityResult(OpenDocument()) { uri: Uri? -> registerForActivityResult(OpenDocument()) { uri: Uri? ->
if (uri == null) return@registerForActivityResult if (uri == null) return@registerForActivityResult
try { try {
copySshKey(uri) SshKey.import(uri)
Toast.makeText( Toast.makeText(
this, this,
this.resources.getString(R.string.ssh_key_success_dialog_title), this.resources.getString(R.string.ssh_key_success_dialog_title),
Toast.LENGTH_LONG Toast.LENGTH_LONG
).show() ).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) setResult(RESULT_OK)
finish() finish()
} catch (e: Exception) { } catch (e: Exception) {
MaterialAlertDialogBuilder(this) MaterialAlertDialogBuilder(this)
@ -566,6 +561,25 @@ class UserPreference : AppCompatActivity() {
}.launch(arrayOf("*/*")) }.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 * Exports the passwords
*/ */
@ -638,36 +652,6 @@ class UserPreference : AppCompatActivity() {
}.launch(arrayOf("*/*")) }.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 private val isAccessibilityServiceEnabled: Boolean
get() { get() {
val am = getSystemService<AccessibilityManager>() ?: return false val am = getSystemService<AccessibilityManager>() ?: return false

View file

@ -16,7 +16,6 @@ import android.view.autofill.AutofillId
import android.widget.RemoteViews import android.widget.RemoteViews
import android.widget.Toast import android.widget.Toast
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.preference.PreferenceManager
import com.github.ajalt.timberkt.Timber.tag import com.github.ajalt.timberkt.Timber.tag
import com.github.ajalt.timberkt.e import com.github.ajalt.timberkt.e
import com.zeapo.pwdstore.R import com.zeapo.pwdstore.R

View file

@ -19,6 +19,7 @@ sealed class GitException(@StringRes res: Int, vararg fmt: String) : Exception(b
override val message = super.message!! override val message = super.message!!
companion object { companion object {
private fun buildMessage(@StringRes res: Int, vararg fmt: String) = Application.instance.resources.getString(res, *fmt) 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]. * Encapsulates possible errors from a [org.eclipse.jgit.api.PullCommand].
*/ */
sealed class PullException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) { sealed class PullException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) {
object PullRebaseFailed : PullException(R.string.git_pull_fail_error) object PullRebaseFailed : PullException(R.string.git_pull_fail_error)
} }

View file

@ -15,8 +15,8 @@ import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.git.GitException.PullException import com.zeapo.pwdstore.git.GitException.PullException
import com.zeapo.pwdstore.git.GitException.PushException import com.zeapo.pwdstore.git.GitException.PushException
import com.zeapo.pwdstore.git.config.GitSettings 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.operation.GitOperation
import com.zeapo.pwdstore.git.sshj.SshjSessionFactory
import com.zeapo.pwdstore.utils.Result import com.zeapo.pwdstore.utils.Result
import com.zeapo.pwdstore.utils.snackbar import com.zeapo.pwdstore.utils.snackbar
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers

View file

@ -5,6 +5,7 @@
package com.zeapo.pwdstore.git.operation package com.zeapo.pwdstore.git.operation
import android.content.Intent import android.content.Intent
import android.widget.Toast
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.core.content.edit import androidx.core.content.edit
import androidx.fragment.app.FragmentActivity 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.config.GitSettings
import com.zeapo.pwdstore.git.sshj.InteractivePasswordFinder import com.zeapo.pwdstore.git.sshj.InteractivePasswordFinder
import com.zeapo.pwdstore.git.sshj.SshAuthData 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.git.sshj.SshjSessionFactory
import com.zeapo.pwdstore.utils.BiometricAuthenticator
import com.zeapo.pwdstore.utils.PasswordRepository import com.zeapo.pwdstore.utils.PasswordRepository
import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.PreferenceKeys
import com.zeapo.pwdstore.utils.getEncryptedPrefs import com.zeapo.pwdstore.utils.getEncryptedPrefs
import com.zeapo.pwdstore.utils.sharedPrefs import com.zeapo.pwdstore.utils.sharedPrefs
import java.io.File 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 net.schmizz.sshj.userauth.password.PasswordFinder
import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.Git
import org.eclipse.jgit.api.GitCommand 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.SshSessionFactory
import org.eclipse.jgit.transport.URIish import org.eclipse.jgit.transport.URIish
const val ANDROID_KEYSTORE_ALIAS_SSH_KEY = "ssh_key"
/** /**
* Creates a new git operation * 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>> abstract val commands: Array<GitCommand<out Any>>
private var provider: CredentialsProvider? = null private var provider: CredentialsProvider? = null
private val sshKeyFile = callingActivity.filesDir.resolve(".ssh_key")
private val hostKeyFile = callingActivity.filesDir.resolve(".host_key") private val hostKeyFile = callingActivity.filesDir.resolve(".host_key")
protected var finishFromErrorDialog = true protected var finishFromErrorDialog = true
protected val repository = PasswordRepository.getRepository(gitDir) protected val repository = PasswordRepository.getRepository(gitDir)
@ -61,9 +69,10 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: Fragment
when (item) { when (item) {
is CredentialItem.Username -> item.value = uri?.user is CredentialItem.Username -> item.value = uri?.user
is CredentialItem.Password -> { is CredentialItem.Password -> {
item.value = cachedPassword?.clone() ?: passwordFinder.reqPassword(null).also { item.value = cachedPassword?.clone()
cachedPassword = it.clone() ?: passwordFinder.reqPassword(null).also {
} cachedPassword = it.clone()
}
} }
else -> UnsupportedCredentialItem(uri, item.javaClass.name) else -> UnsupportedCredentialItem(uri, item.javaClass.name)
} }
@ -88,8 +97,8 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: Fragment
return this return this
} }
private fun withPublicKeyAuthentication(passphraseFinder: InteractivePasswordFinder): GitOperation { private fun withSshKeyAuthentication(passphraseFinder: InteractivePasswordFinder): GitOperation {
val sessionFactory = SshjSessionFactory(SshAuthData.PublicKeyFile(sshKeyFile, passphraseFinder), hostKeyFile) val sessionFactory = SshjSessionFactory(SshAuthData.SshKey(passphraseFinder), hostKeyFile)
SshSessionFactory.setInstance(sessionFactory) SshSessionFactory.setInstance(sessionFactory)
this.provider = null this.provider = null
return this return this
@ -126,27 +135,58 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: Fragment
*/ */
abstract suspend fun execute() 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( suspend fun executeAfterAuthentication(
authMode: AuthMode, authMode: AuthMode,
) { ) {
when (authMode) { when (authMode) {
AuthMode.SshKey -> if (!sshKeyFile.exists()) { AuthMode.SshKey -> if (SshKey.exists) {
MaterialAlertDialogBuilder(callingActivity) if (SshKey.mustAuthenticate) {
.setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text)) val result = withContext(Dispatchers.Main) {
.setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title)) suspendCoroutine<BiometricAuthenticator.Result> { cont ->
.setPositiveButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_import)) { _, _ -> BiometricAuthenticator.authenticate(callingActivity, R.string.biometric_prompt_title_ssh_auth) {
getSshKey(false) if (it !is BiometricAuthenticator.Result.Failure)
cont.resume(it)
}
}
} }
.setNegativeButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate)) { _, _ -> when (result) {
getSshKey(true) 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)) { _, _ -> } else {
// Finish the blank GitActivity so user doesn't have to press back withSshKeyAuthentication(CredentialFinder(callingActivity, authMode)).execute()
callingActivity.finish() }
}.show()
} else { } else {
withPublicKeyAuthentication( onMissingSshKeyFile()
CredentialFinder(callingActivity, authMode)).execute()
} }
AuthMode.OpenKeychain -> withOpenKeychainAuthentication(callingActivity).execute() AuthMode.OpenKeychain -> withOpenKeychainAuthentication(callingActivity).execute()
AuthMode.Password -> withPasswordAuthentication( AuthMode.Password -> withPasswordAuthentication(

View file

@ -22,8 +22,6 @@ import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext 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.DisconnectReason
import net.schmizz.sshj.common.KeyType import net.schmizz.sshj.common.KeyType
import net.schmizz.sshj.userauth.UserAuthException import net.schmizz.sshj.userauth.UserAuthException
@ -46,7 +44,7 @@ class OpenKeychainKeyProvider private constructor(private val activity: Fragment
companion object { companion object {
suspend fun prepareAndUse(activity: FragmentActivity, block: (provider: OpenKeychainKeyProvider) -> Unit) { suspend fun prepareAndUse(activity: FragmentActivity, block: (provider: OpenKeychainKeyProvider) -> Unit) {
withContext(Dispatchers.Main){ withContext(Dispatchers.Main) {
OpenKeychainKeyProvider(activity) OpenKeychainKeyProvider(activity)
}.prepareAndUse(block) }.prepareAndUse(block)
} }
@ -118,10 +116,8 @@ class OpenKeychainKeyProvider private constructor(private val activity: Fragment
is ApiResponse.Success -> { is ApiResponse.Success -> {
val response = sshPublicKeyResponse.response as SshPublicKeyResponse val response = sshPublicKeyResponse.response as SshPublicKeyResponse
val sshPublicKey = response.sshPublicKey!! val sshPublicKey = response.sshPublicKey!!
val sshKeyParts = sshPublicKey.split("""\s+""".toRegex()) publicKey = parseSshPublicKey(sshPublicKey)
check(sshKeyParts.size >= 2) { "OpenKeychain API returned invalid SSH key" } ?: throw IllegalStateException("OpenKeychain API returned invalid SSH key")
@Suppress("BlockingMethodInNonBlockingContext")
publicKey = Buffer.PlainBuffer(Base64.decode(sshKeyParts[1])).readPublicKey()
} }
is ApiResponse.NoSuchKey -> if (isRetry) { is ApiResponse.NoSuchKey -> if (isRetry) {
throw sshPublicKeyResponse.exception throw sshPublicKeyResponse.exception

View file

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

View file

@ -15,6 +15,7 @@ import java.security.Security
import net.schmizz.keepalive.KeepAliveProvider import net.schmizz.keepalive.KeepAliveProvider
import net.schmizz.sshj.ConfigImpl import net.schmizz.sshj.ConfigImpl
import net.schmizz.sshj.common.LoggerFactory import net.schmizz.sshj.common.LoggerFactory
import net.schmizz.sshj.common.SecurityUtils
import net.schmizz.sshj.transport.compression.NoneCompression import net.schmizz.sshj.transport.compression.NoneCompression
import net.schmizz.sshj.transport.kex.Curve25519SHA256 import net.schmizz.sshj.transport.kex.Curve25519SHA256
import net.schmizz.sshj.transport.kex.Curve25519SHA256.FactoryLibSsh import net.schmizz.sshj.transport.kex.Curve25519SHA256.FactoryLibSsh
@ -52,6 +53,9 @@ fun setUpBouncyCastleForSshj() {
Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1) Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1)
} }
d { "JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}" } 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 { private abstract class AbstractLogger(private val name: String) : Logger {

View file

@ -37,7 +37,7 @@ import org.eclipse.jgit.util.FS
sealed class SshAuthData { sealed class SshAuthData {
class Password(val passwordFinder: InteractivePasswordFinder) : 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() class OpenKeychain(val activity: FragmentActivity) : SshAuthData()
} }
@ -127,8 +127,8 @@ private class SshjSession(uri: URIish, private val username: String, private val
is SshAuthData.Password -> { is SshAuthData.Password -> {
ssh.authPassword(username, authData.passwordFinder) ssh.authPassword(username, authData.passwordFinder)
} }
is SshAuthData.PublicKeyFile -> { is SshAuthData.SshKey -> {
ssh.authPublickey(username, ssh.loadKeys(authData.keyFile.absolutePath, authData.passphraseFinder)) ssh.authPublickey(username, SshKey.provide(ssh, authData.passphraseFinder))
} }
is SshAuthData.OpenKeychain -> { is SshAuthData.OpenKeychain -> {
runBlocking { runBlocking {

View file

@ -4,61 +4,35 @@
*/ */
package com.zeapo.pwdstore.sshkeygen package com.zeapo.pwdstore.sshkeygen
import android.annotation.SuppressLint
import android.app.Dialog import android.app.Dialog
import android.content.ClipData import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.zeapo.pwdstore.R import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.utils.clipboard import com.zeapo.pwdstore.git.sshj.SshKey
import java.io.File
class ShowSshKeyFragment : DialogFragment() { 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 { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val activity = requireActivity() val activity = requireActivity()
val view = activity.layoutInflater.inflate(R.layout.fragment_show_ssh_key, null) val publicKey = SshKey.sshPublicKey
publicKey = view.findViewById(R.id.public_key) return MaterialAlertDialogBuilder(requireActivity()).run {
readKeyFromFile() setMessage(getString(R.string.ssh_keygen_message, publicKey))
createMaterialDialog(view) setTitle(R.string.your_public_key)
val ad = builder.create() setNegativeButton(R.string.ssh_keygen_later) { _, _ ->
ad.setOnShowListener { (activity as? SshKeyGenActivity)?.finish()
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)
} }
} setPositiveButton(R.string.ssh_keygen_share) { _, _ ->
return ad val sendIntent = Intent().apply {
} action = Intent.ACTION_SEND
type = "text/plain"
private fun createMaterialDialog(view: View) { putExtra(Intent.EXTRA_TEXT, publicKey)
builder.setView(view) }
builder.setTitle(getString(R.string.your_public_key)) startActivity(Intent.createChooser(sendIntent, null))
builder.setNegativeButton(R.string.dialog_ok) { _, _ -> requireActivity().finish() } (activity as? SshKeyGenActivity)?.finish()
builder.setPositiveButton(R.string.ssh_keygen_copy, null) }
} create()
private fun readKeyFromFile() {
val file = File(requireActivity().filesDir.toString() + "/.ssh_key.pub")
try {
publicKey.text = file.readText()
} catch (e: Exception) {
e.printStackTrace()
} }
} }
} }

View file

@ -5,6 +5,7 @@
package com.zeapo.pwdstore.sshkeygen package com.zeapo.pwdstore.sshkeygen
import android.os.Bundle import android.os.Bundle
import android.security.keystore.UserNotAuthenticatedException
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
@ -13,22 +14,34 @@ import androidx.core.content.edit
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder 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.R
import com.zeapo.pwdstore.databinding.ActivitySshKeygenBinding 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.getEncryptedPrefs
import com.zeapo.pwdstore.utils.sharedPrefs import com.zeapo.pwdstore.utils.keyguardManager
import com.zeapo.pwdstore.utils.viewBinding import com.zeapo.pwdstore.utils.viewBinding
import java.io.File import kotlin.coroutines.resume
import java.io.FileOutputStream import kotlin.coroutines.suspendCoroutine
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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() { class SshKeyGenActivity : AppCompatActivity() {
private var keyLength = 4096 private var keyGenType = KeyGenType.Ecdsa
private val binding by viewBinding(ActivitySshKeygenBinding::inflate) private val binding by viewBinding(ActivitySshKeygenBinding::inflate)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -37,17 +50,45 @@ class SshKeyGenActivity : AppCompatActivity() {
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
with(binding) { with(binding) {
generate.setOnClickListener { generate.setOnClickListener {
lifecycleScope.launch { generate(passphrase.text.toString(), comment.text.toString()) } if (SshKey.exists) {
} MaterialAlertDialogBuilder(this@SshKeyGenActivity).run {
keyLengthGroup.check(R.id.key_length_4096) setTitle(R.string.ssh_keygen_existing_title)
keyLengthGroup.addOnButtonCheckedListener { _, checkedId, isChecked -> setMessage(R.string.ssh_keygen_existing_message)
if (isChecked) { setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ ->
when (checkedId) { lifecycleScope.launch {
R.id.key_length_2048 -> keyLength = 2048 generate()
R.id.key_length_4096 -> keyLength = 4096 }
}
setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ ->
finish()
}
show()
}
} else {
lifecycleScope.launch {
generate()
} }
} }
} }
keyTypeGroup.check(R.id.key_type_ecdsa)
keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa)
keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (isChecked) {
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) binding.generate.text = getString(R.string.ssh_key_gen_generating_progress)
val e = try { val e = try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val kp = KeyPair.genKeyPair(JSch(), KeyPair.RSA, keyLength) val requireAuthentication = binding.keyRequireAuthentication.isChecked
var file = File(filesDir, ".ssh_key") if (requireAuthentication) {
var out = FileOutputStream(file, false) val result = withContext(Dispatchers.Main) {
if (passphrase.isNotEmpty()) { suspendCoroutine<BiometricAuthenticator.Result> { cont ->
kp?.writePrivateKey(out, passphrase.toByteArray()) BiometricAuthenticator.authenticate(this@SshKeyGenActivity, R.string.biometric_prompt_title_ssh_keygen) {
} else { cont.resume(it)
kp?.writePrivateKey(out) }
}
}
if (result !is BiometricAuthenticator.Result.Success)
throw UserNotAuthenticatedException(getString(R.string.biometric_auth_generic_failure))
} }
file = File(filesDir, ".ssh_key.pub") keyGenType.generateKey(requireAuthentication)
out = FileOutputStream(file, false)
kp?.writePublicKey(out, comment)
} }
null null
} catch (e: Exception) { } catch (e: Exception) {
@ -87,11 +134,13 @@ class SshKeyGenActivity : AppCompatActivity() {
remove("ssh_key_local_passphrase") 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) { if (e == null) {
val df = ShowSshKeyFragment() val df = ShowSshKeyFragment()
df.show(supportFragmentManager, "public_key") df.show(supportFragmentManager, "public_key")
sharedPrefs.edit { putBoolean("use_generated_key", true) }
} else { } else {
MaterialAlertDialogBuilder(this) MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.error_generate_ssh_key)) .setTitle(getString(R.string.error_generate_ssh_key))

View file

@ -5,12 +5,12 @@
package com.zeapo.pwdstore.utils package com.zeapo.pwdstore.utils
import android.app.KeyguardManager import android.app.KeyguardManager
import android.os.Handler
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.biometric.BiometricConstants import androidx.biometric.BiometricConstants
import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators import androidx.biometric.BiometricManager.Authenticators
import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import com.github.ajalt.timberkt.Timber.tag import com.github.ajalt.timberkt.Timber.tag
@ -20,7 +20,6 @@ import com.zeapo.pwdstore.R
object BiometricAuthenticator { object BiometricAuthenticator {
private const val TAG = "BiometricAuthenticator" private const val TAG = "BiometricAuthenticator"
private val handler = Handler()
sealed class Result { sealed class Result {
data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result() data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result()
@ -69,7 +68,7 @@ object BiometricAuthenticator {
.setTitle(activity.getString(dialogTitleRes)) .setTitle(activity.getString(dialogTitleRes))
.setAllowedAuthenticators(validAuthenticators) .setAllowedAuthenticators(validAuthenticators)
.build() .build()
BiometricPrompt(activity, { handler.post(it) }, authCallback).authenticate(promptInfo) BiometricPrompt(activity, ContextCompat.getMainExecutor(activity.applicationContext), authCallback).authenticate(promptInfo)
} else { } else {
callback(Result.HardwareUnavailableOrDisabled) callback(Result.HardwareUnavailableOrDisabled)
} }

View file

@ -4,6 +4,7 @@
*/ */
package com.zeapo.pwdstore.utils package com.zeapo.pwdstore.utils
import android.app.KeyguardManager
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -162,6 +163,9 @@ val Context.autofillManager: AutofillManager?
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
get() = getSystemService() get() = getSystemService()
val Context.keyguardManager: KeyguardManager
get() = getSystemService()!!
fun File.isInsideRepository(): Boolean { fun File.isInsideRepository(): Boolean {
return canonicalPath.contains(getRepositoryDirectory().canonicalPath) return canonicalPath.contains(getRepositoryDirectory().canonicalPath)
} }

View file

@ -28,6 +28,7 @@ object PreferenceKeys {
const val GIT_EXTERNAL = "git_external" const val GIT_EXTERNAL = "git_external"
const val GIT_EXTERNAL_REPO = "git_external_repo" const val GIT_EXTERNAL_REPO = "git_external_repo"
const val GIT_REMOTE_AUTH = "git_remote_auth" const val GIT_REMOTE_AUTH = "git_remote_auth"
const val GIT_REMOTE_KEY_TYPE = "git_remote_key_type"
@Deprecated("Use GIT_REMOTE_URL instead") @Deprecated("Use GIT_REMOTE_URL instead")
const val GIT_REMOTE_LOCATION = "git_remote_location" 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_CLEAR_KEY_ID = "ssh_openkeystore_clear_keyid"
const val SSH_OPENKEYSTORE_KEYID = "ssh_openkeystore_keyid" const val SSH_OPENKEYSTORE_KEYID = "ssh_openkeystore_keyid"
const val SSH_SEE_KEY = "ssh_see_key" const val SSH_SEE_KEY = "ssh_see_key"
const val USE_GENERATED_KEY = "use_generated_key"
} }

View file

@ -13,16 +13,11 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:paddingLeft="@dimen/activity_horizontal_margin" android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
android:paddingRight="@dimen/activity_horizontal_margin"> android:paddingRight="@dimen/activity_horizontal_margin">
<androidx.appcompat.widget.AppCompatTextView
android:layout_width="wrap_content"
android:layout_height="48dp"
android:gravity="center_vertical"
android:text="@string/ssh_keygen_length" />
<com.google.android.material.button.MaterialButtonToggleGroup <com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/key_length_group" android:id="@+id/key_type_group"
style="@style/TextAppearance.MaterialComponents.Headline1" style="@style/TextAppearance.MaterialComponents.Headline1"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -30,48 +25,38 @@
app:singleSelection="true"> app:singleSelection="true">
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/key_length_2048" android:id="@+id/key_type_rsa"
style="?attr/materialButtonOutlinedStyle" style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/key_length_2048" /> android:text="@string/ssh_keygen_label_rsa" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/key_length_4096" android:id="@+id/key_type_ecdsa"
style="?attr/materialButtonOutlinedStyle" style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/key_length_4096" /> android:text="@string/ssh_keygen_label_ecdsa" />
<com.google.android.material.button.MaterialButton
android:id="@+id/key_type_ed25519"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/ssh_keygen_label_ed25519" />
</com.google.android.material.button.MaterialButtonToggleGroup> </com.google.android.material.button.MaterialButtonToggleGroup>
<com.google.android.material.textfield.TextInputLayout <androidx.appcompat.widget.AppCompatTextView
android:id="@+id/key_type_explanation"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:paddingTop="8dp" />
android:hint="@string/ssh_keygen_passphrase"
app:endIconMode="password_toggle">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/passphrase" android:id="@+id/key_require_authentication"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="@font/sourcecodepro"
android:importantForAccessibility="no"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:text="@string/ssh_keygen_require_authentication" />
android:hint="@string/ssh_keygen_comment">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/comment"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/generate" android:id="@+id/generate"

View file

@ -31,7 +31,7 @@
android:id="@+id/set_gpg_key" android:id="@+id/set_gpg_key"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
android:text="@string/new_folder_set_gpg_key" android:text="@string/new_folder_set_gpg_key"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/folder_name_container" /> app:layout_constraintTop_toBottomOf="@id/folder_name_container" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,32 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
~ SPDX-License-Identifier: GPL-3.0-only
-->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingLeft="24dp"
android:paddingTop="20dp"
android:paddingRight="24dp"
android:paddingBottom="20dp">
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/public_key"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textIsSelectable="true" />
<androidx.appcompat.widget.AppCompatTextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/ssh_keygen_tip"
android:textSize="16sp" />
</LinearLayout>
</ScrollView>

View file

@ -14,8 +14,8 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:endIconMode="password_toggle" app:endIconMode="password_toggle"
app:hintEnabled="true"
app:errorEnabled="true" app:errorEnabled="true"
app:hintEnabled="true"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"> app:layout_constraintTop_toTopOf="parent">

View file

@ -87,7 +87,6 @@
<string name="ssh_keygen_passphrase">العبارة السرية</string> <string name="ssh_keygen_passphrase">العبارة السرية</string>
<string name="ssh_keygen_comment">تعليق</string> <string name="ssh_keygen_comment">تعليق</string>
<string name="ssh_keygen_generate">توليد</string> <string name="ssh_keygen_generate">توليد</string>
<string name="ssh_keygen_copy">نسخ</string>
<!-- Misc --> <!-- Misc -->
<string name="dialog_ok">حسناً</string> <string name="dialog_ok">حسناً</string>

View file

@ -130,8 +130,6 @@
<string name="ssh_keygen_passphrase">Bezpečnostní fráze</string> <string name="ssh_keygen_passphrase">Bezpečnostní fráze</string>
<string name="ssh_keygen_comment">Komentář</string> <string name="ssh_keygen_comment">Komentář</string>
<string name="ssh_keygen_generate">Generovat</string> <string name="ssh_keygen_generate">Generovat</string>
<string name="ssh_keygen_copy">Kopírovat</string>
<string name="ssh_keygen_tip">Přidat tento veřejný klíč na Git server.</string>
<!-- Misc --> <!-- Misc -->
<string name="dialog_ok">OK</string> <string name="dialog_ok">OK</string>

View file

@ -111,8 +111,6 @@
<string name="ssh_keygen_passphrase">Passwort</string> <string name="ssh_keygen_passphrase">Passwort</string>
<string name="ssh_keygen_comment">Kommentar</string> <string name="ssh_keygen_comment">Kommentar</string>
<string name="ssh_keygen_generate">Generieren</string> <string name="ssh_keygen_generate">Generieren</string>
<string name="ssh_keygen_copy">Kopieren</string>
<string name="ssh_keygen_tip">Füge den Public-Key zu deinem Git-Server hinzu.</string>
<!-- Misc --> <!-- Misc -->
<string name="dialog_ok">OK</string> <string name="dialog_ok">OK</string>

View file

@ -135,8 +135,6 @@
<string name="ssh_keygen_passphrase">Contraseña</string> <string name="ssh_keygen_passphrase">Contraseña</string>
<string name="ssh_keygen_comment">Comentario</string> <string name="ssh_keygen_comment">Comentario</string>
<string name="ssh_keygen_generate">Generar</string> <string name="ssh_keygen_generate">Generar</string>
<string name="ssh_keygen_copy">Copiar</string>
<string name="ssh_keygen_tip">Registra esta llave pública en tu servidor Git.</string>
<!-- Misc --> <!-- Misc -->
<string name="dialog_ok">OK</string> <string name="dialog_ok">OK</string>

View file

@ -135,8 +135,6 @@
<string name="ssh_keygen_passphrase">Mot de passe</string> <string name="ssh_keygen_passphrase">Mot de passe</string>
<string name="ssh_keygen_comment">Commentaire</string> <string name="ssh_keygen_comment">Commentaire</string>
<string name="ssh_keygen_generate">Générer</string> <string name="ssh_keygen_generate">Générer</string>
<string name="ssh_keygen_copy">Copier</string>
<string name="ssh_keygen_tip">Enregistrez cette clef publique sur votre serveur Git.</string>
<!-- Misc --> <!-- Misc -->
<string name="dialog_ok">OK</string> <string name="dialog_ok">OK</string>

View file

@ -94,8 +94,6 @@
<string name="ssh_keygen_passphrase">パスフレーズ</string> <string name="ssh_keygen_passphrase">パスフレーズ</string>
<string name="ssh_keygen_comment">コメント</string> <string name="ssh_keygen_comment">コメント</string>
<string name="ssh_keygen_generate">生成</string> <string name="ssh_keygen_generate">生成</string>
<string name="ssh_keygen_copy">コピー</string>
<string name="ssh_keygen_tip">この公開鍵を Git サーバーに提供してください。</string>
<!-- Misc --> <!-- Misc -->
<string name="dialog_ok">OK</string> <string name="dialog_ok">OK</string>

View file

@ -169,8 +169,6 @@
<string name="ssh_keygen_passphrase">Frase Secreta</string> <string name="ssh_keygen_passphrase">Frase Secreta</string>
<string name="ssh_keygen_comment">Comentário</string> <string name="ssh_keygen_comment">Comentário</string>
<string name="ssh_keygen_generate">Gerar</string> <string name="ssh_keygen_generate">Gerar</string>
<string name="ssh_keygen_copy">Copiar</string>
<string name="ssh_keygen_tip">Forneça esta chave pública para seu servidor Git.</string>
<string name="ssh_key_gen_generating_progress">Gerando chaves…</string> <string name="ssh_key_gen_generating_progress">Gerando chaves…</string>
<string name="ssh_keygen_generating_done">Concluído!</string> <string name="ssh_keygen_generating_done">Concluído!</string>
<!-- Misc --> <!-- Misc -->

View file

@ -164,8 +164,6 @@
<string name="ssh_keygen_passphrase">Пароль</string> <string name="ssh_keygen_passphrase">Пароль</string>
<string name="ssh_keygen_comment">Комментарий</string> <string name="ssh_keygen_comment">Комментарий</string>
<string name="ssh_keygen_generate">Сгенерировать</string> <string name="ssh_keygen_generate">Сгенерировать</string>
<string name="ssh_keygen_copy">Скоприровать</string>
<string name="ssh_keygen_tip">Поместите публичный ключ на сервер Git</string>
<!-- Misc --> <!-- Misc -->
<string name="dialog_ok">OK</string> <string name="dialog_ok">OK</string>

View file

@ -91,8 +91,6 @@
<string name="ssh_keygen_passphrase">口令</string> <string name="ssh_keygen_passphrase">口令</string>
<string name="ssh_keygen_comment">备注</string> <string name="ssh_keygen_comment">备注</string>
<string name="ssh_keygen_generate">生成</string> <string name="ssh_keygen_generate">生成</string>
<string name="ssh_keygen_copy">复制</string>
<string name="ssh_keygen_tip">在你的Git服务器上提供此公钥</string>
<!-- Misc --> <!-- Misc -->
<string name="dialog_ok">确定</string> <string name="dialog_ok">确定</string>

View file

@ -88,8 +88,6 @@
<string name="ssh_keygen_passphrase">密碼</string> <string name="ssh_keygen_passphrase">密碼</string>
<string name="ssh_keygen_comment">備註</string> <string name="ssh_keygen_comment">備註</string>
<string name="ssh_keygen_generate">產生</string> <string name="ssh_keygen_generate">產生</string>
<string name="ssh_keygen_copy">複製</string>
<string name="ssh_keygen_tip">在你的 Git 伺服器上提供此公鑰</string>
<!-- Misc --> <!-- Misc -->
<string name="dialog_ok">確定</string> <string name="dialog_ok">確定</string>

View file

@ -199,12 +199,29 @@
<string name="ssh_keygen_passphrase">Passphrase</string> <string name="ssh_keygen_passphrase">Passphrase</string>
<string name="ssh_keygen_comment">Comment</string> <string name="ssh_keygen_comment">Comment</string>
<string name="ssh_keygen_generate">Generate</string> <string name="ssh_keygen_generate">Generate</string>
<string name="ssh_keygen_copy">Copy</string> <string name="ssh_keygen_share">Share</string>
<string name="ssh_keygen_tip">Provide this public key to your Git server.</string> <string name="ssh_keygen_later">Later</string>
<string name="ssh_keygen_message">%1$s\n\nProvide this public key to your Git server.</string>
<string name="ssh_key_gen_generating_progress">Generating keys…</string> <string name="ssh_key_gen_generating_progress">Generating keys…</string>
<string name="ssh_keygen_generating_done">Done!</string> <string name="ssh_keygen_generating_done">Done!</string>
<string name="key_length_2048" translatable="false">2048</string> <string name="ssh_keygen_require_authentication">Protect with screen lock credential</string>
<string name="key_length_4096" translatable="false">4096</string> <string name="ssh_keygen_copied_key">Public key copied to clipboard</string>
<string name="ssh_keygen_label_rsa">RSA</string>
<string name="ssh_keygen_label_ecdsa">ECDSA</string>
<string name="ssh_keygen_label_ed25519">Ed25519</string>
<string name="ssh_keygen_explanation_rsa"><b>RSA (3072 bit)</b>\nSupported by all servers, but authentication is comparatively slow.</string>
<string name="ssh_keygen_explanation_ecdsa"><b>ECDSA (NIST P-256)</b>\nFast authentication and supported by most servers that are still receiving updates.</string>
<string name="ssh_keygen_explanation_ed25519"><b>Ed25519</b>\nFast authentication, but only supported by rather modern servers.</string>
<string name="ssh_keygen_existing_title">SSH key</string>
<string name="ssh_keygen_existing_message">Replace existing SSH key? You might lose access to your server.</string>
<string name="ssh_keygen_existing_replace">Replace</string>
<string name="ssh_keygen_existing_keep">Keep</string>
<!-- SSH Android Keystore auth -->
<string name="biometric_auth_generic_failure">Screen lock authentication failed</string>
<string name="biometric_prompt_title_ssh_auth">Unlock SSH key</string>
<string name="biometric_prompt_title_ssh_keygen">Generate SSH key</string>
<!-- Misc --> <!-- Misc -->
<string name="dialog_ok">OK</string> <string name="dialog_ok">OK</string>

View file

@ -55,8 +55,8 @@ object Dependencies {
const val bouncycastle = "org.bouncycastle:bcprov-jdk15on:1.66" const val bouncycastle = "org.bouncycastle:bcprov-jdk15on:1.66"
const val commons_codec = "commons-codec:commons-codec:1.14" const val commons_codec = "commons-codec:commons-codec:1.14"
const val eddsa = "net.i2p.crypto:eddsa:0.3.0"
const val fastscroll = "me.zhanghai.android.fastscroll:library:1.1.4" const val fastscroll = "me.zhanghai.android.fastscroll:library:1.1.4"
const val jsch = "com.jcraft:jsch:0.1.55"
const val jgit = "org.eclipse.jgit:org.eclipse.jgit:3.7.1.201504261725-r" const val jgit = "org.eclipse.jgit:org.eclipse.jgit:3.7.1.201504261725-r"
const val leakcanary = "com.squareup.leakcanary:leakcanary-android:2.4" const val leakcanary = "com.squareup.leakcanary:leakcanary-android:2.4"
const val plumber = "com.squareup.leakcanary:plumber-android:2.4" const val plumber = "com.squareup.leakcanary:plumber-android:2.4"