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

Add biometrics sample #10

Merged
merged 11 commits into from
Sep 3, 2020
10 changes: 10 additions & 0 deletions BiometricSample/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
*.iml
.gradle
/local.properties
/.idea/*
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
19 changes: 19 additions & 0 deletions BiometricSample/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Biometric Authentication

Android sample app to learn about biometric authentication APIs, how to use them with cryptography APIs and how to fallback to non-biometric authentication.

![biometric sample app](https://github.com/husaynhakeem/android-playground/blob/master/BiometricSample/art/biometric_sample.png)

The app mainly showcases:
- Checking if a device supports biometric authentication using [BiometricManager]().
- Displaying a biometric authentication prompt using [biometricPrompt](https://developer.android.com/reference/androidx/biometric/BiometricPrompt).
- Generating and storing secret keys in the KeyStore, then using biometrics to protect the encryption key and provide an extra layer of security. This uses [CryptoObject](https://developer.android.com/reference/androidx/biometric/BiometricPrompt.CryptoObject) and [Cipher](https://developer.android.com/reference/javax/crypto/Cipher) to handle encryption and decryption.
- Configuring the biometric prompt to control settings like requiring the user's confirmation after a biometric authentication.
- Falling back to non-biometric credentials to authenticate, including a PIN, password or pattern.

There are certain difference in what the biometric and cryptography APIs offer and support across Android API levels. The differences produce 3 groups of API levels that share the same features:
- Pre Android Marshmellow (API level 23)
- From Android Marshmellow (API level 23) to Android R (API level 30) [exclusive]
- From Android R (API level 30) and onwards

Given these differences, and to clearly understand the biometric and cryptography APIs, this sample defines compatibility classes (think of Android's compatibility classes, not as fancy though) that expose interfaces ([BiometricAuthenticator](https://github.com/husaynhakeem/android-playground/blob/biometric/BiometricSample/app/src/main/java/com/husaynhakeem/biometricsample/biometric/BiometricAuthenticator.kt) and [CryptographyManager](https://github.com/husaynhakeem/android-playground/blob/biometric/BiometricSample/app/src/main/java/com/husaynhakeem/biometricsample/crypto/CryptographyManager.kt)) meant to be used by a client (The client in this scenario being the app's [main Activity](https://github.com/husaynhakeem/android-playground/blob/biometric/BiometricSample/app/src/main/java/com/husaynhakeem/biometricsample/MainActivity.kt)). These abstractions list the expected behaviors they support, and hide the detail implementations on different API levels from the client.
1 change: 1 addition & 0 deletions BiometricSample/app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
33 changes: 33 additions & 0 deletions BiometricSample/app/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "kotlin-android-extensions"
}

android {
compileSdkVersion 30
buildToolsVersion "29.0.3"

defaultConfig {
applicationId "com.husaynhakeem.biometricsample"
minSdkVersion 21
targetSdkVersion 30
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}

dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "androidx.core:core-ktx:1.3.1"
implementation "androidx.appcompat:appcompat:1.2.0"
implementation "com.google.android.material:material:1.2.0"
implementation "androidx.constraintlayout:constraintlayout:2.0.1"
implementation "androidx.biometric:biometric:1.1.0-alpha02"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.husaynhakeem.biometricsample

import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4

import org.junit.Test
import org.junit.runner.RunWith

import org.junit.Assert.*

/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.husaynhakeem.biometricsample", appContext.packageName)
}
}
19 changes: 19 additions & 0 deletions BiometricSample/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.husaynhakeem.biometricsample">

<application
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.BiometricSample">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.husaynhakeem.biometricsample

import android.annotation.SuppressLint
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.husaynhakeem.biometricsample.biometric.BiometricAuthenticator
import kotlinx.android.synthetic.main.layout_authenticate.*
import kotlinx.android.synthetic.main.layout_authentication_confirmation.*
import kotlinx.android.synthetic.main.layout_authenticator_types.*
import kotlinx.android.synthetic.main.layout_configuration_change.*
import kotlinx.android.synthetic.main.layout_logging.*
import kotlinx.android.synthetic.main.layout_negative_button.*


class MainActivity : AppCompatActivity() {

private lateinit var biometricAuthenticator: BiometricAuthenticator

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

biometricAuthenticator =
BiometricAuthenticator.instance(this, object : BiometricAuthenticator.Listener {
override fun onNewMessage(message: String) {
log(message)
}
})

canAuthenticate.setOnClickListener { biometricAuthenticator.canAuthenticate(this) }
authenticate.setOnClickListener { biometricAuthenticator.authenticateWithoutCrypto(this) }
authenticateEncrypt.setOnClickListener { biometricAuthenticator.authenticateAndEncrypt(this) }
authenticateDecrypt.setOnClickListener { biometricAuthenticator.authenticateAndDecrypt(this) }

// Check =box listeners
authenticatorStrong.setOnCheckedChangeListener { _, isChecked ->
biometricAuthenticator.isStrongAuthenticationEnabled = isChecked
}
authenticatorWeak.setOnCheckedChangeListener { _, isChecked ->
biometricAuthenticator.isWeakAuthenticationEnabled = isChecked
}
authenticatorDeviceCredential.setOnCheckedChangeListener { _, isChecked ->
biometricAuthenticator.isDeviceCredentialAuthenticationEnabled = isChecked
}
negativeButton.setOnCheckedChangeListener { _, isChecked ->
biometricAuthenticator.showNegativeButton = isChecked
}
authenticationConfirmation.setOnCheckedChangeListener { _, isChecked ->
biometricAuthenticator.showAuthenticationConfirmation = isChecked
}

// Initial states
biometricAuthenticator.isStrongAuthenticationEnabled = authenticatorStrong.isChecked
biometricAuthenticator.isWeakAuthenticationEnabled = authenticatorWeak.isChecked
biometricAuthenticator.isDeviceCredentialAuthenticationEnabled =
authenticatorDeviceCredential.isChecked
biometricAuthenticator.showNegativeButton = negativeButton.isChecked
biometricAuthenticator.showAuthenticationConfirmation = authenticationConfirmation.isChecked

clearLogs.setOnClickListener { clearLogs() }
}

override fun onStop() {
super.onStop()
if (isChangingConfigurations && !keepAuthenticationDialogOnConfigurationChange()) {
biometricAuthenticator.cancelAuthentication()
}
}

private fun keepAuthenticationDialogOnConfigurationChange(): Boolean {
return configurationChange.isChecked
}

@SuppressLint("SetTextI18n")
private fun log(message: String) {
val currentLogs = logs.text.toString()
logs.text = "$message\n$currentLogs"
}

private fun clearLogs() {
logs.text = ""
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package com.husaynhakeem.biometricsample.biometric

import android.content.Context
import android.os.Build
import androidx.biometric.BiometricPrompt
import androidx.biometric.BiometricPrompt.PromptInfo
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import com.husaynhakeem.biometricsample.R
import com.husaynhakeem.biometricsample.crypto.CryptographyManager
import com.husaynhakeem.biometricsample.crypto.EncryptedData
import com.husaynhakeem.biometricsample.crypto.EncryptionMode

abstract class BiometricAuthenticator(
activity: FragmentActivity,
protected val listener: Listener
) {

var showNegativeButton = false
var isDeviceCredentialAuthenticationEnabled = false
var isStrongAuthenticationEnabled = false
var isWeakAuthenticationEnabled = false
var showAuthenticationConfirmation = false

/** Handle using biometrics + cryptography to encrypt/decrypt data securely */
protected val cryptographyManager = CryptographyManager.instance()
protected lateinit var encryptionMode: EncryptionMode
protected lateinit var encryptedData: EncryptedData

/** Receives callbacks from an authentication operation */
private val authenticationCallback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
listener.onNewMessage("Authentication succeeded")

val type = result.authenticationType
val cryptoObject = result.cryptoObject
listener.onNewMessage("Type: ${getAuthenticationType(type)} - Crypto: $cryptoObject")

val cipher = cryptoObject?.cipher ?: return
when (encryptionMode) {
EncryptionMode.ENCRYPT -> {
encryptedData = cryptographyManager.encrypt(PAYLOAD, cipher)
listener.onNewMessage("Encrypted text: ${encryptedData.encrypted}")
}
EncryptionMode.DECRYPT -> {
val plainData = cryptographyManager.decrypt(encryptedData.encrypted, cipher)
listener.onNewMessage("Decrypted text: $plainData")
}
}
}

override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
listener.onNewMessage("Authentication error[${getBiometricError(errorCode)}] - $errString")
}

override fun onAuthenticationFailed() {
listener.onNewMessage("Authentication failed - Biometric is valid but not recognized")
}
}

/** Manages a biometric prompt, and allows to perform an authentication operation */
protected val biometricPrompt =
BiometricPrompt(activity, ContextCompat.getMainExecutor(activity), authenticationCallback)

abstract fun canAuthenticate(context: Context)

fun authenticateWithoutCrypto(context: Context) {
val promptInfo = buildPromptInfo(context) ?: return
biometricPrompt.authenticate(promptInfo)
}

abstract fun authenticateAndEncrypt(context: Context)

abstract fun authenticateAndDecrypt(context: Context)

abstract fun setAllowedAuthenticators(builder: PromptInfo.Builder)

fun cancelAuthentication() {
biometricPrompt.cancelAuthentication()
}

/** Build a [PromptInfo] that defines the properties of the biometric prompt dialog. */
protected fun buildPromptInfo(context: Context): PromptInfo? {
val builder = PromptInfo.Builder()
.setTitle(context.getString(R.string.prompt_title))
.setSubtitle(context.getString(R.string.prompt_subtitle))
.setDescription(context.getString(R.string.prompt_description))

// Show a confirmation button after authentication succeeds
builder.setConfirmationRequired(showAuthenticationConfirmation)

// Allow authentication with a password, pin or pattern
setAllowedAuthenticators(builder)

// Set a negative button. It would typically display "Cancel"
if (showNegativeButton) {
builder.setNegativeButtonText(context.getString(R.string.prompt_negative_text))
}

return try {
builder.build()
} catch (exception: IllegalArgumentException) {
listener.onNewMessage("Building prompt info error - ${exception.message}")
null
}
}

interface Listener {
fun onNewMessage(message: String)
}

companion object {
private const val PAYLOAD = "Biometrics sample"

fun instance(activity: FragmentActivity, listener: Listener): BiometricAuthenticator {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return BiometricAuthenticatorLegacy(activity, listener)
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
return BiometricAuthenticatorApi23(activity, listener)
}
return BiometricAuthenticatorApi30(activity, listener)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.husaynhakeem.biometricsample.biometric

import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.fragment.app.FragmentActivity
import com.husaynhakeem.biometricsample.crypto.EncryptionMode

@RequiresApi(Build.VERSION_CODES.M)
class BiometricAuthenticatorApi23(activity: FragmentActivity, listener: Listener) :
BiometricAuthenticator(activity, listener) {

@Suppress("DEPRECATION")
override fun canAuthenticate(context: Context) {
val biometricManager = BiometricManager.from(context)
val canAuthenticate = biometricManager.canAuthenticate()
listener.onNewMessage(getBiometricAvailability(canAuthenticate))
}

override fun authenticateAndEncrypt(context: Context) {
val promptInfo = buildPromptInfo(context) ?: return
val cipher = cryptographyManager.getCipherForEncryption()
val crypto = BiometricPrompt.CryptoObject(cipher)
encryptionMode = EncryptionMode.ENCRYPT

try {
biometricPrompt.authenticate(promptInfo, crypto)
} catch (exception: IllegalArgumentException) {
listener.onNewMessage("Authentication with crypto error - ${exception.message}")
}
}

override fun authenticateAndDecrypt(context: Context) {
val promptInfo = buildPromptInfo(context) ?: return
val cipher = cryptographyManager.getCipherForDecryption(encryptedData.initializationVector)
val crypto = BiometricPrompt.CryptoObject(cipher)
encryptionMode = EncryptionMode.DECRYPT

try {
biometricPrompt.authenticate(promptInfo, crypto)
} catch (exception: IllegalArgumentException) {
listener.onNewMessage("Authentication with crypto error - ${exception.message}")
}
}

@Suppress("DEPRECATION")
override fun setAllowedAuthenticators(builder: BiometricPrompt.PromptInfo.Builder) {
builder.setDeviceCredentialAllowed(isDeviceCredentialAuthenticationEnabled)
}
}
Loading