diff --git a/build.gradle b/build.gradle index 7710893f..e1bbdf79 100644 --- a/build.gradle +++ b/build.gradle @@ -7,27 +7,28 @@ allprojects { buildscript { ext { + agpVersion = '3.6.1' annotationsVersion = '1.1.0' appcompatVersion = '1.1.0' + bintrayPluginVersion = '1.8.4' + biometricVersion = '1.0.1' cardviewVersion = '1.0.0' collectionVersion = '1.1.0' - coreKtxVersion = '1.2.0' - coroutinesVersion = '1.3.5' constraintLayoutVersion = '1.1.3' coordinatorLayoutVersion = '1.1.0' - agpVersion = '3.6.1' + coreKtxVersion = '1.2.0' + coroutinesVersion = '1.3.5' + eddsaVersion = '0.3.0' fragmentVersion = '1.2.3' - materialComponentsVersion = '1.1.0' jsr305Version = '3.0.2' + junitVersion = '4.13' kotlinVersion = '1.3.71' + materialComponentsVersion = '1.1.0' + mavenPluginVersion = '2.1' preferenceVersion = '1.1.0' streamsupportVersion = '1.7.2' threetenabpVersion = '1.2.3' zxingEmbeddedVersion = '3.6.0' - eddsaVersion = '0.3.0' - bintrayPluginVersion = '1.8.4' - mavenPluginVersion = '2.1' - junitVersion = '4.13' groupName = 'com.wireguard.android' } diff --git a/ui/build.gradle b/ui/build.gradle index b5b47c51..009a717f 100644 --- a/ui/build.gradle +++ b/ui/build.gradle @@ -68,6 +68,7 @@ dependencies { implementation "androidx.cardview:cardview:$cardviewVersion" implementation "androidx.constraintlayout:constraintlayout:$constraintLayoutVersion" implementation "androidx.coordinatorlayout:coordinatorlayout:$coordinatorLayoutVersion" + implementation "androidx.biometric:biometric:$biometricVersion" implementation "androidx.core:core-ktx:$coreKtxVersion" implementation "androidx.databinding:databinding-runtime:$agpVersion" implementation "androidx.fragment:fragment:$fragmentVersion" diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt index 9ac2473c..1b8af50e 100644 --- a/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt +++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt @@ -25,6 +25,7 @@ import com.wireguard.android.backend.Tunnel import com.wireguard.android.databinding.TunnelEditorFragmentBinding import com.wireguard.android.fragment.AppListDialogFragment.AppExclusionListener import com.wireguard.android.model.ObservableTunnel +import com.wireguard.android.util.BiometricAuthenticator import com.wireguard.android.util.ErrorMessages import com.wireguard.android.viewmodel.ConfigProxy import com.wireguard.android.widget.EdgeToEdge.setUpRoot @@ -233,13 +234,27 @@ class TunnelEditorFragment : BaseFragment(), AppExclusionListener { if (!isFocused) return val edit = view as? EditText ?: return if (!haveShownKeys && edit.text.isNotEmpty()) { - if (true /* TODO: do biometric auth prompt */) { - haveShownKeys = true - } else { - /* Unauthorized, so return and don't change visibility. */ - return + BiometricAuthenticator.authenticate(R.string.biometric_prompt_private_key_title, requireActivity()) { + when (it) { + is BiometricAuthenticator.Result.Success, is BiometricAuthenticator.Result.HardwareUnavailableOrDisabled -> { + haveShownKeys = true + showPrivateKey(edit) + } + is BiometricAuthenticator.Result.Failure -> { + Snackbar.make( + binding!!.mainContainer, + it.message, + Snackbar.LENGTH_SHORT + ).show() + } + } } + } else { + showPrivateKey(edit) } + } + + private fun showPrivateKey(edit: EditText) { requireActivity().window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) edit.inputType = InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD } diff --git a/ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.kt b/ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.kt index d0cb02f0..c83c1cca 100644 --- a/ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.kt +++ b/ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.kt @@ -14,6 +14,7 @@ import com.google.android.material.snackbar.Snackbar import com.wireguard.android.Application import com.wireguard.android.R import com.wireguard.android.model.ObservableTunnel +import com.wireguard.android.util.BiometricAuthenticator import com.wireguard.android.util.DownloadsFileSaver import com.wireguard.android.util.ErrorMessages import com.wireguard.android.util.FragmentUtils @@ -81,13 +82,27 @@ class ZipExporterPreference(context: Context, attrs: AttributeSet?) : Preference override fun getTitle() = context.getString(R.string.zip_export_title) override fun onClick() { - FragmentUtils.getPrefActivity(this) - .ensurePermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { _, grantResults -> - if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - isEnabled = false - exportZip() + val prefActivity = FragmentUtils.getPrefActivity(this) + BiometricAuthenticator.authenticate(R.string.biometric_prompt_zip_exporter_title, prefActivity) { + when (it) { + // When we have successful authentication, or when there is no biometric hardware available. + is BiometricAuthenticator.Result.Success, is BiometricAuthenticator.Result.HardwareUnavailableOrDisabled -> { + prefActivity.ensurePermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { _, grantResults -> + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + isEnabled = false + exportZip() + } } } + is BiometricAuthenticator.Result.Failure -> { + Snackbar.make( + prefActivity.findViewById(android.R.id.content), + it.message, + Snackbar.LENGTH_SHORT + ).show() + } + } + } } companion object { diff --git a/ui/src/main/java/com/wireguard/android/util/BiometricAuthenticator.kt b/ui/src/main/java/com/wireguard/android/util/BiometricAuthenticator.kt new file mode 100644 index 00000000..cf81f768 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/util/BiometricAuthenticator.kt @@ -0,0 +1,73 @@ +/* + * Copyright © 2020 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.util + +import android.os.Handler +import android.util.Log +import androidx.annotation.StringRes +import androidx.biometric.BiometricConstants +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.fragment.app.FragmentActivity +import com.wireguard.android.R + +object BiometricAuthenticator { + private const val TAG = "WireGuard/BiometricAuthenticator" + private val handler = Handler() + + sealed class Result { + data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result() + data class Failure(val code: Int?, val message: CharSequence) : Result() + object HardwareUnavailableOrDisabled : Result() + object Cancelled : Result() + } + + fun authenticate( + @StringRes dialogTitleRes: Int, + fragmentActivity: FragmentActivity, + callback: (Result) -> Unit + ) { + val biometricManager = BiometricManager.from(fragmentActivity) + val authCallback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + Log.d(TAG, "BiometricAuthentication error: errorCode=$errorCode, msg=$errString") + callback(when (errorCode) { + BiometricConstants.ERROR_CANCELED, BiometricConstants.ERROR_USER_CANCELED, + BiometricConstants.ERROR_NEGATIVE_BUTTON -> { + Result.Cancelled + } + BiometricConstants.ERROR_HW_NOT_PRESENT, BiometricConstants.ERROR_HW_UNAVAILABLE, + BiometricConstants.ERROR_NO_BIOMETRICS, BiometricConstants.ERROR_NO_DEVICE_CREDENTIAL -> { + Result.HardwareUnavailableOrDisabled + } + else -> Result.Failure(errorCode, fragmentActivity.getString(R.string.biometric_auth_error_reason, errString)) + }) + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + callback(Result.Failure(null, fragmentActivity.getString(R.string.biometric_auth_error))) + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + callback(Result.Success(result.cryptoObject)) + } + } + val biometricPrompt = BiometricPrompt(fragmentActivity, { handler.post(it) }, authCallback) + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(fragmentActivity.getString(dialogTitleRes)) + .setDeviceCredentialAllowed(true) + .build() + + if (biometricManager.canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS) { + biometricPrompt.authenticate(promptInfo) + } else { + callback(Result.HardwareUnavailableOrDisabled) + } + } +} diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 8e5c28d1..50bc40b8 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -191,4 +191,8 @@ Saved to “%s” Zip file will be saved to downloads folder Export tunnels to zip file + Authenticate to export tunnels + Authenticate to view private key + Authentication failure + Authentication failure: %s