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
- 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
- Allow generating ECDSA and ED25519 keys for SSH
### Changed
- 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
- '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

View file

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

View file

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

View file

@ -14,8 +14,8 @@ import com.github.ajalt.timberkt.Timber.DebugTree
import com.github.ajalt.timberkt.Timber.plant
import com.zeapo.pwdstore.git.sshj.setUpBouncyCastleForSshj
import com.zeapo.pwdstore.utils.PreferenceKeys
import com.zeapo.pwdstore.utils.sharedPrefs
import com.zeapo.pwdstore.utils.getString
import com.zeapo.pwdstore.utils.sharedPrefs
@Suppress("Unused")
class Application : android.app.Application(), SharedPreferences.OnSharedPreferenceChangeListener {
@ -45,7 +45,8 @@ class Application : android.app.Application(), SharedPreferences.OnSharedPrefere
}
private fun setNightMode() {
AppCompatDelegate.setDefaultNightMode(when (sharedPrefs.getString(PreferenceKeys.APP_THEME) ?: getString(R.string.app_theme_def)) {
AppCompatDelegate.setDefaultNightMode(when (sharedPrefs.getString(PreferenceKeys.APP_THEME)
?: getString(R.string.app_theme_def)) {
"light" -> MODE_NIGHT_NO
"dark" -> MODE_NIGHT_YES
"follow_system" -> MODE_NIGHT_FOLLOW_SYSTEM

View file

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

View file

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

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.PasswordCreationActivity
import com.zeapo.pwdstore.git.BaseGitActivity
import com.zeapo.pwdstore.git.log.GitLogActivity
import com.zeapo.pwdstore.git.GitOperationActivity
import com.zeapo.pwdstore.git.GitServerConfigActivity
import com.zeapo.pwdstore.git.config.AuthMode

View file

@ -15,7 +15,6 @@ import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import android.provider.Settings
import android.text.TextUtils
import android.view.MenuItem
@ -45,6 +44,7 @@ import com.zeapo.pwdstore.autofill.oreo.getInstalledBrowsersWithAutofillSupportL
import com.zeapo.pwdstore.crypto.BasePgpActivity
import com.zeapo.pwdstore.git.GitConfigActivity
import com.zeapo.pwdstore.git.GitServerConfigActivity
import com.zeapo.pwdstore.git.sshj.SshKey
import com.zeapo.pwdstore.pwgenxkpwd.XkpwdDictionary
import com.zeapo.pwdstore.sshkeygen.ShowSshKeyFragment
import com.zeapo.pwdstore.sshkeygen.SshKeyGenActivity
@ -56,7 +56,6 @@ import com.zeapo.pwdstore.utils.getEncryptedPrefs
import com.zeapo.pwdstore.utils.getString
import com.zeapo.pwdstore.utils.sharedPrefs
import java.io.File
import java.io.IOException
typealias ClickListener = Preference.OnPreferenceClickListener
typealias ChangeListener = Preference.OnPreferenceChangeListener
@ -69,6 +68,7 @@ class UserPreference : AppCompatActivity() {
private var autoFillEnablePreference: SwitchPreferenceCompat? = null
private var clearSavedPassPreference: Preference? = null
private var viewSshKeyPreference: Preference? = null
private lateinit var autofillDependencies: List<Preference>
private lateinit var oreoAutofillDependencies: List<Preference>
private lateinit var prefsActivity: UserPreference
@ -89,8 +89,8 @@ class UserPreference : AppCompatActivity() {
val gitConfigPreference = findPreference<Preference>(PreferenceKeys.GIT_CONFIG)
val sshKeyPreference = findPreference<Preference>(PreferenceKeys.SSH_KEY)
val sshKeygenPreference = findPreference<Preference>(PreferenceKeys.SSH_KEYGEN)
viewSshKeyPreference = findPreference<Preference>(PreferenceKeys.SSH_SEE_KEY)
clearSavedPassPreference = findPreference(PreferenceKeys.CLEAR_SAVED_PASS)
val viewSshKeyPreference = findPreference<Preference>(PreferenceKeys.SSH_SEE_KEY)
val deleteRepoPreference = findPreference<Preference>(PreferenceKeys.GIT_DELETE_REPO)
val externalGitRepositoryPreference = findPreference<Preference>(PreferenceKeys.GIT_EXTERNAL)
val selectExternalGitRepositoryPreference = findPreference<Preference>(PreferenceKeys.PREF_SELECT_EXTERNAL)
@ -141,8 +141,8 @@ class UserPreference : AppCompatActivity() {
// Misc preferences
val appVersionPreference = findPreference<Preference>(PreferenceKeys.APP_VERSION)
selectExternalGitRepositoryPreference?.summary = sharedPreferences.getString(PreferenceKeys.GIT_EXTERNAL_REPO) ?: getString(R.string.no_repo_selected)
viewSshKeyPreference?.isVisible = sharedPreferences.getBoolean(PreferenceKeys.USE_GENERATED_KEY, false)
selectExternalGitRepositoryPreference?.summary = sharedPreferences.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
?: getString(R.string.no_repo_selected)
deleteRepoPreference?.isVisible = !sharedPreferences.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)
clearClipboard20xPreference?.isVisible = sharedPreferences.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toInt() != 0
openkeystoreIdPreference?.isVisible = sharedPreferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty()
@ -226,7 +226,8 @@ class UserPreference : AppCompatActivity() {
}
selectExternalGitRepositoryPreference?.summary =
sharedPreferences.getString(PreferenceKeys.GIT_EXTERNAL_REPO) ?: context.getString(R.string.no_repo_selected)
sharedPreferences.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
?: context.getString(R.string.no_repo_selected)
selectExternalGitRepositoryPreference?.onPreferenceClickListener = ClickListener {
prefsActivity.selectExternalGitRepository()
true
@ -393,6 +394,10 @@ class UserPreference : AppCompatActivity() {
}
}
private fun updateViewSshPubkeyPref() {
viewSshKeyPreference?.isVisible = SshKey.canShowSshPublicKey
}
private fun onEnableAutofillClick() {
if (prefsActivity.isAccessibilityServiceEnabled) {
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
@ -451,6 +456,7 @@ class UserPreference : AppCompatActivity() {
super.onResume()
updateAutofillSettings()
updateClearSavedPassphrasePrefs()
updateViewSshPubkeyPref()
}
}
@ -532,29 +538,18 @@ class UserPreference : AppCompatActivity() {
}
}
/**
* Opens a file explorer to import the private key
*/
private fun getSshKey() {
private fun importSshKey() {
registerForActivityResult(OpenDocument()) { uri: Uri? ->
if (uri == null) return@registerForActivityResult
try {
copySshKey(uri)
SshKey.import(uri)
Toast.makeText(
this,
this.resources.getString(R.string.ssh_key_success_dialog_title),
Toast.LENGTH_LONG
).show()
val prefs = sharedPrefs
prefs.edit { putBoolean(PreferenceKeys.USE_GENERATED_KEY, false) }
getEncryptedPrefs("git_operation").edit { remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) }
// Delete the public key from generation
File("""$filesDir/.ssh_key.pub""").delete()
setResult(RESULT_OK)
finish()
} catch (e: Exception) {
MaterialAlertDialogBuilder(this)
@ -566,6 +561,25 @@ class UserPreference : AppCompatActivity() {
}.launch(arrayOf("*/*"))
}
/**
* Opens a file explorer to import the private key
*/
private fun getSshKey() {
if (SshKey.exists) {
MaterialAlertDialogBuilder(this).run {
setTitle(R.string.ssh_keygen_existing_title)
setMessage(R.string.ssh_keygen_existing_message)
setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ ->
importSshKey()
}
setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> }
show()
}
} else {
importSshKey()
}
}
/**
* Exports the passwords
*/
@ -638,36 +652,6 @@ class UserPreference : AppCompatActivity() {
}.launch(arrayOf("*/*"))
}
@Throws(IllegalArgumentException::class, IOException::class)
private fun copySshKey(uri: Uri) {
// First check whether the content at uri is likely an SSH private key.
val fileSize = contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)
?.use { cursor ->
// Cursor returns only a single row.
cursor.moveToFirst()
cursor.getInt(0)
} ?: throw IOException(getString(R.string.ssh_key_does_not_exist))
// We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes.
if (fileSize > 100_000 || fileSize == 0)
throw IllegalArgumentException(getString(R.string.ssh_key_import_error_not_an_ssh_key_message))
val sshKeyInputStream = contentResolver.openInputStream(uri)
?: throw IOException(getString(R.string.ssh_key_does_not_exist))
val lines = sshKeyInputStream.bufferedReader().readLines()
// The file must have more than 2 lines, and the first and last line must have private key
// markers.
if (lines.size < 2 ||
!Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) ||
!Regex("END .* PRIVATE KEY").containsMatchIn(lines.last())
)
throw IllegalArgumentException(getString(R.string.ssh_key_import_error_not_an_ssh_key_message))
// Canonicalize line endings to '\n'.
File("$filesDir/.ssh_key").writeText(lines.joinToString("\n"))
}
private val isAccessibilityServiceEnabled: Boolean
get() {
val am = getSystemService<AccessibilityManager>() ?: return false

View file

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

View file

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

View file

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

View file

@ -5,6 +5,7 @@
package com.zeapo.pwdstore.git.operation
import android.content.Intent
import android.widget.Toast
import androidx.annotation.CallSuper
import androidx.core.content.edit
import androidx.fragment.app.FragmentActivity
@ -17,12 +18,18 @@ import com.zeapo.pwdstore.git.config.AuthMode
import com.zeapo.pwdstore.git.config.GitSettings
import com.zeapo.pwdstore.git.sshj.InteractivePasswordFinder
import com.zeapo.pwdstore.git.sshj.SshAuthData
import com.zeapo.pwdstore.git.sshj.SshKey
import com.zeapo.pwdstore.git.sshj.SshjSessionFactory
import com.zeapo.pwdstore.utils.BiometricAuthenticator
import com.zeapo.pwdstore.utils.PasswordRepository
import com.zeapo.pwdstore.utils.PreferenceKeys
import com.zeapo.pwdstore.utils.getEncryptedPrefs
import com.zeapo.pwdstore.utils.sharedPrefs
import java.io.File
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.schmizz.sshj.userauth.password.PasswordFinder
import org.eclipse.jgit.api.Git
import org.eclipse.jgit.api.GitCommand
@ -33,6 +40,8 @@ import org.eclipse.jgit.transport.CredentialsProvider
import org.eclipse.jgit.transport.SshSessionFactory
import org.eclipse.jgit.transport.URIish
const val ANDROID_KEYSTORE_ALIAS_SSH_KEY = "ssh_key"
/**
* Creates a new git operation
*
@ -43,7 +52,6 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: Fragment
abstract val commands: Array<GitCommand<out Any>>
private var provider: CredentialsProvider? = null
private val sshKeyFile = callingActivity.filesDir.resolve(".ssh_key")
private val hostKeyFile = callingActivity.filesDir.resolve(".host_key")
protected var finishFromErrorDialog = true
protected val repository = PasswordRepository.getRepository(gitDir)
@ -61,9 +69,10 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: Fragment
when (item) {
is CredentialItem.Username -> item.value = uri?.user
is CredentialItem.Password -> {
item.value = cachedPassword?.clone() ?: passwordFinder.reqPassword(null).also {
cachedPassword = it.clone()
}
item.value = cachedPassword?.clone()
?: passwordFinder.reqPassword(null).also {
cachedPassword = it.clone()
}
}
else -> UnsupportedCredentialItem(uri, item.javaClass.name)
}
@ -88,8 +97,8 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: Fragment
return this
}
private fun withPublicKeyAuthentication(passphraseFinder: InteractivePasswordFinder): GitOperation {
val sessionFactory = SshjSessionFactory(SshAuthData.PublicKeyFile(sshKeyFile, passphraseFinder), hostKeyFile)
private fun withSshKeyAuthentication(passphraseFinder: InteractivePasswordFinder): GitOperation {
val sessionFactory = SshjSessionFactory(SshAuthData.SshKey(passphraseFinder), hostKeyFile)
SshSessionFactory.setInstance(sessionFactory)
this.provider = null
return this
@ -126,27 +135,58 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: Fragment
*/
abstract suspend fun execute()
private fun onMissingSshKeyFile() {
MaterialAlertDialogBuilder(callingActivity)
.setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text))
.setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title))
.setPositiveButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_import)) { _, _ ->
getSshKey(false)
}
.setNegativeButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate)) { _, _ ->
getSshKey(true)
}
.setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ ->
// Finish the blank GitActivity so user doesn't have to press back
callingActivity.finish()
}.show()
}
suspend fun executeAfterAuthentication(
authMode: AuthMode,
) {
when (authMode) {
AuthMode.SshKey -> if (!sshKeyFile.exists()) {
MaterialAlertDialogBuilder(callingActivity)
.setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text))
.setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title))
.setPositiveButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_import)) { _, _ ->
getSshKey(false)
AuthMode.SshKey -> if (SshKey.exists) {
if (SshKey.mustAuthenticate) {
val result = withContext(Dispatchers.Main) {
suspendCoroutine<BiometricAuthenticator.Result> { cont ->
BiometricAuthenticator.authenticate(callingActivity, R.string.biometric_prompt_title_ssh_auth) {
if (it !is BiometricAuthenticator.Result.Failure)
cont.resume(it)
}
}
}
.setNegativeButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate)) { _, _ ->
getSshKey(true)
when (result) {
is BiometricAuthenticator.Result.Success -> {
withSshKeyAuthentication(CredentialFinder(callingActivity, authMode)).execute()
}
is BiometricAuthenticator.Result.Cancelled -> callingActivity.finish()
is BiometricAuthenticator.Result.Failure -> {
throw IllegalStateException("Biometric authentication failures should be ignored")
}
else -> {
// There is a chance we succeed if the user recently confirmed
// their screen lock. Doing so would have a potential to confuse
// users though, who might deduce that the screen lock
// protection is not effective. Hence, we fail with an error.
Toast.makeText(callingActivity.applicationContext, R.string.biometric_auth_generic_failure, Toast.LENGTH_LONG).show()
callingActivity.finish()
}
}
.setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ ->
// Finish the blank GitActivity so user doesn't have to press back
callingActivity.finish()
}.show()
} else {
withSshKeyAuthentication(CredentialFinder(callingActivity, authMode)).execute()
}
} else {
withPublicKeyAuthentication(
CredentialFinder(callingActivity, authMode)).execute()
onMissingSshKeyFile()
}
AuthMode.OpenKeychain -> withOpenKeychainAuthentication(callingActivity).execute()
AuthMode.Password -> withPasswordAuthentication(

View file

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

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.sshj.ConfigImpl
import net.schmizz.sshj.common.LoggerFactory
import net.schmizz.sshj.common.SecurityUtils
import net.schmizz.sshj.transport.compression.NoneCompression
import net.schmizz.sshj.transport.kex.Curve25519SHA256
import net.schmizz.sshj.transport.kex.Curve25519SHA256.FactoryLibSsh
@ -52,6 +53,9 @@ fun setUpBouncyCastleForSshj() {
Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1)
}
d { "JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}" }
// Prevent sshj from forwarding all cryptographic operations to BC.
SecurityUtils.setRegisterBouncyCastle(false)
SecurityUtils.setSecurityProvider(null)
}
private abstract class AbstractLogger(private val name: String) : Logger {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -28,6 +28,7 @@ object PreferenceKeys {
const val GIT_EXTERNAL = "git_external"
const val GIT_EXTERNAL_REPO = "git_external_repo"
const val GIT_REMOTE_AUTH = "git_remote_auth"
const val GIT_REMOTE_KEY_TYPE = "git_remote_key_type"
@Deprecated("Use GIT_REMOTE_URL instead")
const val GIT_REMOTE_LOCATION = "git_remote_location"
@ -75,6 +76,4 @@ object PreferenceKeys {
const val SSH_OPENKEYSTORE_CLEAR_KEY_ID = "ssh_openkeystore_clear_keyid"
const val SSH_OPENKEYSTORE_KEYID = "ssh_openkeystore_keyid"
const val SSH_SEE_KEY = "ssh_see_key"
const val USE_GENERATED_KEY = "use_generated_key"
}

View file

@ -13,16 +13,11 @@
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
android:paddingRight="@dimen/activity_horizontal_margin">
<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
android:id="@+id/key_length_group"
android:id="@+id/key_type_group"
style="@style/TextAppearance.MaterialComponents.Headline1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -30,48 +25,38 @@
app:singleSelection="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/key_length_2048"
android:id="@+id/key_type_rsa"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="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
android:id="@+id/key_length_4096"
android:id="@+id/key_type_ecdsa"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="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.textfield.TextInputLayout
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/key_type_explanation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/ssh_keygen_passphrase"
app:endIconMode="password_toggle">
android:paddingTop="8dp" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/passphrase"
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
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/key_require_authentication"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
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>
android:text="@string/ssh_keygen_require_authentication" />
<com.google.android.material.button.MaterialButton
android:id="@+id/generate"

View file

@ -31,7 +31,7 @@
android:id="@+id/set_gpg_key"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
android:text="@string/new_folder_set_gpg_key"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/folder_name_container" />
</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_height="wrap_content"
app:endIconMode="password_toggle"
app:hintEnabled="true"
app:errorEnabled="true"
app:hintEnabled="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">

View file

@ -87,7 +87,6 @@
<string name="ssh_keygen_passphrase">العبارة السرية</string>
<string name="ssh_keygen_comment">تعليق</string>
<string name="ssh_keygen_generate">توليد</string>
<string name="ssh_keygen_copy">نسخ</string>
<!-- Misc -->
<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_comment">Komentář</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 -->
<string name="dialog_ok">OK</string>

View file

@ -111,8 +111,6 @@
<string name="ssh_keygen_passphrase">Passwort</string>
<string name="ssh_keygen_comment">Kommentar</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 -->
<string name="dialog_ok">OK</string>

View file

@ -135,8 +135,6 @@
<string name="ssh_keygen_passphrase">Contraseña</string>
<string name="ssh_keygen_comment">Comentario</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 -->
<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_comment">Commentaire</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 -->
<string name="dialog_ok">OK</string>

View file

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

View file

@ -169,8 +169,6 @@
<string name="ssh_keygen_passphrase">Frase Secreta</string>
<string name="ssh_keygen_comment">Comentário</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_keygen_generating_done">Concluído!</string>
<!-- Misc -->

View file

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

View file

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

View file

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

View file

@ -199,12 +199,29 @@
<string name="ssh_keygen_passphrase">Passphrase</string>
<string name="ssh_keygen_comment">Comment</string>
<string name="ssh_keygen_generate">Generate</string>
<string name="ssh_keygen_copy">Copy</string>
<string name="ssh_keygen_tip">Provide this public key to your Git server.</string>
<string name="ssh_keygen_share">Share</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_keygen_generating_done">Done!</string>
<string name="key_length_2048" translatable="false">2048</string>
<string name="key_length_4096" translatable="false">4096</string>
<string name="ssh_keygen_require_authentication">Protect with screen lock credential</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 -->
<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 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 jsch = "com.jcraft:jsch:0.1.55"
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 plumber = "com.squareup.leakcanary:plumber-android:2.4"