Add Keystore backend for SSH public key authentication (#1070)
This commit is contained in:
parent
55d64fb737
commit
cbb96397d1
37 changed files with 583 additions and 258 deletions
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
1
app/proguard-rules.pro
vendored
1
app/proguard-rules.pro
vendored
|
@ -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.** { *; }
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
|
|
321
app/src/main/java/com/zeapo/pwdstore/git/sshj/SshKey.kt
Normal file
321
app/src/main/java/com/zeapo/pwdstore/git/sshj/SshKey.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 -->
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue