Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Let SshKeyGenFragment use the Android Keystore #807

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7be0f58
Let SshKeyGenFragment use the Android Keystore
fmeum May 28, 2020
c7dc749
Update CHANGELOG
msfjarvis May 28, 2020
11339c8
build: add explicit eddsa dependency
msfjarvis May 30, 2020
573a5e6
Refactor Keystore generation and add StrongBox support
fmeum May 31, 2020
92ef071
Merge branch 'master' into fhenneke_sshj_keystore
FabianHenneke May 31, 2020
1107c48
Improve SSH public key dialog UI
fmeum May 31, 2020
84f76ff
Merge branch 'master' into fhenneke_sshj_keystore
FabianHenneke May 31, 2020
6e9de11
Show a toast after copying public key
fmeum May 31, 2020
c2967f1
Improve SSHJ setup and algorithm selection
fmeum Jun 8, 2020
b42b930
Merge remote-tracking branch 'upstream/master' into fhenneke_sshj_key…
fmeum Jun 13, 2020
395f3d8
Fix crash on failed authentication attempt
fmeum Jun 13, 2020
211f1ed
Fix upstream issue #600
fmeum Jun 15, 2020
25b27c7
Merge branch 'master' into fhenneke_sshj_keystore
FabianHenneke Jun 17, 2020
89a9401
Merge branch 'develop' into fhenneke_sshj_keystore
msfjarvis Jul 14, 2020
ce3fb00
Merge branch 'develop' into fhenneke_sshj_keystore
FabianHenneke Jul 20, 2020
c057352
Merge branch 'develop' into fhenneke_sshj_keystore
msfjarvis Jul 20, 2020
191b7f7
Fix bcpkix-jdk15on version
msfjarvis Jul 20, 2020
931ba45
Fix leftover merge conflict
msfjarvis Jul 20, 2020
07e4573
Compilation fixes
msfjarvis Jul 20, 2020
5c69bfb
Update SSHJ snapshot
fmeum Jul 20, 2020
fbc29e7
Replace signatureAlgorithms with keyAlgorithms in SSHJ config
fmeum Jul 20, 2020
678fee3
Merge branch 'develop' into fhenneke_sshj_keystore
FabianHenneke Jul 20, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ All notable changes to this project will be documented in this file.

### Added

- Add support for ECDSA/ed25519 keys
- Add support for modern key exchange protocols like diffie-hellman family
- Move SSH keys from private files to Android Keystore
- TOTP support is reintroduced by popular demand. HOTP continues to be unsupported and heavily discouraged.
- Initial support for detecting and filling OTP fields with Autofill
- OTP codes can be automatically filled from SMS (requires Android P+ and Google Play Services)
Expand Down
5 changes: 4 additions & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,11 @@ dependencies {
exclude group: 'org.apache.httpcomponents', module: 'httpclient'
}
implementation deps.third_party.jsch
implementation deps.third_party.sshj
// FIXME
implementation files('../libs/sshj-0.29.1-SNAPSHOT.jar')
implementation deps.third_party.eddsa
implementation deps.third_party.bouncycastle
implementation deps.third_party.bouncycastle_pkix
implementation deps.third_party.plumber
implementation deps.third_party.ssh_auth
implementation deps.third_party.timber
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.core.content.getSystemService
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.commit
import androidx.lifecycle.ViewModelProvider
Expand Down
121 changes: 105 additions & 16 deletions app/src/main/java/com/zeapo/pwdstore/git/GitOperation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,27 @@ package com.zeapo.pwdstore.git

import android.annotation.SuppressLint
import android.content.Intent
import android.security.keystore.UserNotAuthenticatedException
import android.view.LayoutInflater
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.fragment.app.FragmentActivity
import androidx.preference.PreferenceManager
import com.google.android.material.checkbox.MaterialCheckBox
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.UserPreference
import com.zeapo.pwdstore.git.config.AndroidKeystoreKeyProvider
import com.zeapo.pwdstore.git.config.ConnectionMode
import com.zeapo.pwdstore.git.config.InteractivePasswordFinder
import com.zeapo.pwdstore.git.config.SshApiSessionFactory
import com.zeapo.pwdstore.git.config.SshAuthData
import com.zeapo.pwdstore.git.config.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
Expand All @@ -40,6 +45,8 @@ import org.eclipse.jgit.transport.URIish
import com.google.android.material.R as materialR


const val ANDROID_KEYSTORE_ALIAS_SSH_KEY = "ssh_key"

private class GitOperationCredentialFinder(
val callingActivity: AppCompatActivity,
val connectionMode: ConnectionMode
Expand Down Expand Up @@ -163,6 +170,13 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: AppCompa
return this
}

private fun withAndroidKeystoreAuthentication(username: String): GitOperation {
val sessionFactory = SshjSessionFactory(username, SshAuthData.AndroidKeystoreKey(ANDROID_KEYSTORE_ALIAS_SSH_KEY), hostKeyFile)
SshSessionFactory.setInstance(sessionFactory)
this.provider = null
return this
}

private fun withOpenKeychainAuthentication(username: String, identity: SshApiSessionFactory.ApiIdentity?): GitOperation {
SshSessionFactory.setInstance(SshApiSessionFactory(username, identity))
this.provider = null
Expand All @@ -187,29 +201,104 @@ abstract class GitOperation(gitDir: File, internal val callingActivity: AppCompa
*/
abstract fun execute()

private fun onMissingSshKeyFile() {
MaterialAlertDialogBuilder(callingActivity).run {
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)) { _, _ ->
try {
// Ask the UserPreference to provide us with the ssh-key
// onResult has to be handled by the callingActivity
val intent = Intent(callingActivity.applicationContext, UserPreference::class.java)
intent.putExtra("operation", "get_ssh_key")
callingActivity.startActivityForResult(intent, GET_SSH_KEY_FROM_CLONE)
} catch (e: Exception) {
println("Exception caught :(")
e.printStackTrace()
}
}
setNegativeButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate)) { _, _ ->
try {
// Duplicated code
val intent = Intent(callingActivity.applicationContext, UserPreference::class.java)
intent.putExtra("operation", "make_ssh_key")
callingActivity.startActivityForResult(intent, GET_SSH_KEY_FROM_CLONE)
} catch (e: Exception) {
println("Exception caught :(")
e.printStackTrace()
}
}
setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ ->
// Finish the blank GitActivity so user doesn't have to press back
callingActivity.finish()
}
show()
}
}

/**
* Executes the GitCommand in an async task after creating the authentication
*
* @param connectionMode the server-connection mode
* @param username the username
* @param identity the api identity to use for auth in OpenKeychain connection mode
*/
fun executeAfterAuthentication(
connectionMode: ConnectionMode,
username: String,
identity: SshApiSessionFactory.ApiIdentity?
) {
when (connectionMode) {
ConnectionMode.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)
}
.setNegativeButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate)) { _, _ ->
getSshKey(true)
ConnectionMode.SshKey -> when {
!sshKeyFile.exists() -> {
MaterialAlertDialogBuilder(callingActivity)
.setMessage(R.string.ssh_preferences_dialog_text)
.setTitle(R.string.ssh_preferences_dialog_title)
.setPositiveButton(R.string.ssh_preferences_dialog_import) { _, _ ->
getSshKey(false)
}
.setNegativeButton(R.string.ssh_preferences_dialog_generate) { _, _ ->
getSshKey(true)
}
.setNeutralButton(R.string.dialog_cancel) { _, _ ->
// Finish the blank GitActivity so user doesn't have to press back
callingActivity.finish()
}.show()
}
sshKeyFile.readText() == "keystore" -> {
when (AndroidKeystoreKeyProvider.isUserAuthenticationRequired(ANDROID_KEYSTORE_ALIAS_SSH_KEY)) {
true -> BiometricAuthenticator.authenticate(callingActivity, R.string.biometric_prompt_title_ssh_auth) {
when (it) {
is BiometricAuthenticator.Result.Success -> {
withAndroidKeystoreAuthentication(username).execute()
}
is BiometricAuthenticator.Result.Cancelled -> {
callingActivity.finish()
}
is BiometricAuthenticator.Result.Failure -> {
// Do nothing to allow retries.
}
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()
}
}
}
false -> withAndroidKeystoreAuthentication(username).execute()
else -> {
// The key is no longer available or has been invalidated by screen lock
// deactivation.
sshKeyFile.delete()
onMissingSshKeyFile()
}
}
.setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ ->
// Finish the blank GitActivity so user doesn't have to press back
callingActivity.finish()
}.show()
} else {
withPublicKeyAuthentication(username, GitOperationCredentialFinder(callingActivity,
connectionMode)).execute()
}
else -> withPublicKeyAuthentication(username, GitOperationCredentialFinder(
callingActivity, connectionMode)).execute()
}
ConnectionMode.OpenKeychain -> withOpenKeychainAuthentication(username, identity).execute()
ConnectionMode.Password -> withPasswordAuthentication(
Expand Down
140 changes: 140 additions & 0 deletions app/src/main/java/com/zeapo/pwdstore/git/config/SshjAndroidKeystore.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.git.config

import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyInfo
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties
import com.github.ajalt.timberkt.e
import com.github.ajalt.timberkt.i
import net.schmizz.sshj.common.KeyType
import net.schmizz.sshj.userauth.keyprovider.KeyProvider
import java.io.IOException
import java.security.KeyFactory
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.PrivateKey
import java.security.PublicKey
import java.security.UnrecoverableKeyException


const val PROVIDER_ANDROID_KEY_STORE = "AndroidKeyStore"

private val androidKeystore: KeyStore by lazy {
KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE).apply { load(null) }
}

private fun KeyStore.getPrivateKey(keyAlias: String) = getKey(keyAlias, null) as? PrivateKey

private fun KeyStore.getPublicKey(keyAlias: String) = getCertificate(keyAlias)?.publicKey

enum class SshKeyGenType(private val algorithm: String, private val keyLength: Int,
private val applyToSpec: KeyGenParameterSpec.Builder.() -> Unit) {

Rsa2048(KeyProperties.KEY_ALGORITHM_RSA, 2048, {
setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
setDigests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
}),
Rsa3072(KeyProperties.KEY_ALGORITHM_RSA, 3072, {
setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
setDigests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
}),
Rsa4096(KeyProperties.KEY_ALGORITHM_RSA, 4096, {
setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
setDigests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
}),
Ecdsa256(KeyProperties.KEY_ALGORITHM_EC, 256, {
setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1"))
setDigests(KeyProperties.DIGEST_SHA256)
}),
Ecdsa384(KeyProperties.KEY_ALGORITHM_EC, 384, {
setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp384r1"))
setDigests(KeyProperties.DIGEST_SHA384)
}),
Ecdsa521(KeyProperties.KEY_ALGORITHM_EC, 521, {
setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp521r1"))
setDigests(KeyProperties.DIGEST_SHA512)
});

private fun generateKeyPair(keyAlias: String, requireAuthentication: Boolean, useStrongBox: Boolean): KeyPair {
val parameterSpec = KeyGenParameterSpec.Builder(
keyAlias, KeyProperties.PURPOSE_SIGN
).run {
setKeySize(keyLength)
apply(applyToSpec)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
setIsStrongBoxBacked(useStrongBox)
}
if (requireAuthentication) {
setUserAuthenticationRequired(true)
// 60 seconds should provide ample time to connect to the SSH server and
// perform authentication (and possibly a Git operation and another connect,
// in case of the clone operation).
setUserAuthenticationValidityDurationSeconds(60)
}
build()
}
return KeyPairGenerator.getInstance(algorithm, PROVIDER_ANDROID_KEY_STORE).run {
initialize(parameterSpec)
generateKeyPair()
}
}

fun generateKeyPair(keyAlias: String, requireAuthentication: Boolean): KeyPair {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
try {
return generateKeyPair(
keyAlias, requireAuthentication = requireAuthentication, useStrongBox = true)
} catch (error: Exception) {
i { "Falling back to non-StrongBox Keystore key" }
}
}
return generateKeyPair(
keyAlias, requireAuthentication = requireAuthentication, useStrongBox = false)
}
}


class AndroidKeystoreKeyProvider(private val keyAlias: String) : KeyProvider {

override fun getPublic(): PublicKey = try {
androidKeystore.getPublicKey(keyAlias)!!
} catch (error: Exception) {
e(error)
throw IOException("Failed to get public key '$keyAlias' from Android Keystore")
}

override fun getType(): KeyType = KeyType.fromKey(public)

override fun getPrivate(): PrivateKey = try {
androidKeystore.getPrivateKey(keyAlias)!!
} catch (error: Exception) {
e(error)
throw IOException("Failed to access private key '$keyAlias' from Android Keystore")
}

companion object {
fun isUserAuthenticationRequired(keyAlias: String): Boolean? {
return try {
val key = androidKeystore.getPrivateKey(keyAlias) ?: return null
val factory = KeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE)
factory.getKeySpec(key, KeyInfo::class.java).isUserAuthenticationRequired
} catch (error: Exception) {
if (error is KeyPermanentlyInvalidatedException || error is UnrecoverableKeyException) {
// The user deactivated their screen lock, which invalidates the key. We delete
// it and pretend we didn't find it.
androidKeystore.deleteEntry(keyAlias)
return null
}
// It is fine to swallow the exception here since it will reappear when the key is
// used for authentication and can then be shown in the UI.
true
}
}
}
}
25 changes: 12 additions & 13 deletions app/src/main/java/com/zeapo/pwdstore/git/config/SshjConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,14 @@ package com.zeapo.pwdstore.git.config

import com.github.ajalt.timberkt.Timber
import com.github.ajalt.timberkt.d
import com.hierynomus.sshj.signature.SignatureEdDSA
import com.hierynomus.sshj.key.KeyAlgorithms
import com.hierynomus.sshj.transport.cipher.BlockCiphers
import com.hierynomus.sshj.transport.mac.Macs
import com.hierynomus.sshj.userauth.keyprovider.OpenSSHKeyV1KeyFile
import java.security.Security
import net.schmizz.keepalive.KeepAliveProvider
import net.schmizz.sshj.ConfigImpl
import net.schmizz.sshj.common.LoggerFactory
import net.schmizz.sshj.signature.SignatureECDSA
import net.schmizz.sshj.signature.SignatureRSA
import net.schmizz.sshj.signature.SignatureRSA.FactoryCERT
import net.schmizz.sshj.transport.compression.NoneCompression
import net.schmizz.sshj.transport.kex.Curve25519SHA256
import net.schmizz.sshj.transport.kex.Curve25519SHA256.FactoryLibSsh
Expand Down Expand Up @@ -202,7 +199,7 @@ class SshjConfig : ConfigImpl() {
version = "OpenSSH_8.2p1 Ubuntu-4ubuntu0.1"

initKeyExchangeFactories()
initSignatureFactories()
initKeyAlgorithms()
initRandomFactory()
initFileKeyProviderFactories()
initCipherFactories()
Expand All @@ -221,14 +218,16 @@ class SshjConfig : ConfigImpl() {
)
}

private fun initSignatureFactories() {
signatureFactories = listOf(
SignatureEdDSA.Factory(),
SignatureECDSA.Factory256(),
SignatureECDSA.Factory384(),
SignatureECDSA.Factory521(),
SignatureRSA.Factory(),
FactoryCERT()
private fun initKeyAlgorithms() {
keyAlgorithms = listOf(
KeyAlgorithms.EdDSA25519(),
KeyAlgorithms.RSASHA512(),
KeyAlgorithms.RSASHA256(),
KeyAlgorithms.ECDSASHANistp521(),
KeyAlgorithms.ECDSASHANistp384(),
KeyAlgorithms.ECDSASHANistp256(),
KeyAlgorithms.SSHRSACertV01(),
KeyAlgorithms.SSHRSA()
)
}

Expand Down
Loading