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