[feat] [biometric] Adding the capability of encrypting/decrypting data using biometric authentication on Android (Ref. #2306) (#2454)

* –feat: adding the ability to encrypt/decrypt data based on the biometric auth

Adding the method biometricCipher() that allows to use the biometric authentication to use a generated key from android that can be accessed only through the biometric authentication. The method uses this key to encrypt/decrypt the data provided by the caller in the "options" parameter, with "dataToEncrypt" or "dataToDecrypt" filled.

Changes were done both on Kotlin, Rust and JS sides.

* Improving the way we encrypt data by allowing only if the biometric was successful.

* Adding a warning on the JS side

* Adding doc changes in readme.md

* Adding doc changes in readme.md

* Update README.md - it was updated incomplete

* update docs

* enhance error handling

* update example

---------

Co-authored-by: Lucas Nogueira <lucas@tauri.app>
This commit is contained in:
Charles SchaeferandLucas Nogueira authored and GitHub committed 2026-10-12 10:38:16 -03:00
1 parent b3e52ce036
commit 0374bec1e2
19 files changed
+915 -141

No files matched your search

@@ -16,23 +16,131 @@ import android.os.Handler
import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricPrompt
import java.util.concurrent.Executor
import android.util.Base64
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
//import javax.crypto.spec.IvParameterSpec
import java.nio.charset.Charset
import app.tauri.biometric.CipherType
class BiometricActivity : AppCompatActivity() {
private var ENCRYPTION_ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
private val ENCRYPTION_BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM
private val ENCRYPTION_PADDING = KeyProperties.ENCRYPTION_PADDING_NONE
private val KEYSTORE_NAME = "AndroidKeyStore"
private val KEY_ALIAS = "tauri-plugin-biometric-key"
@SuppressLint("WrongConstant")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.auth_activity)
val executor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
this.mainExecutor
} else {
Executor { command: Runnable? ->
Handler(this.mainLooper).post(
command!!
)
}
cipherOperation = intent.hasExtra(BiometricPlugin.ENCRYPT_DECRYPT_OPERATION)
// null when the activity already finished with an error
val promptInfo = createPromptInfo() ?: return
val prompt = createBiometricPrompt()
if (!cipherOperation) {
prompt.authenticate(promptInfo)
return
}
intent.getStringExtra(BiometricPlugin.ENCRYPT_DECRYPT_DATA)?.let {
encryptDecryptData = it
}
try {
val type = CipherType.values()[intent.getIntExtra(BiometricPlugin.CIPHER_OPERATION_TYPE, CipherType.ENCRYPT.ordinal)]
cipherType = type
} catch (e: ArrayIndexOutOfBoundsException) {
finishActivity(
BiometryResultType.ERROR,
0,
"Couldn't identify the cipher operation type (encrypt/decrypt)!"
)
return
}
// Fails on malformed encrypted data or a key that is no longer valid
// (e.g. invalidated by a new biometric enrollment).
val cipher = try {
getCipher()
} catch (e: Exception) {
finishActivity(
BiometryResultType.ERROR,
0,
"Failed to initialize the cipher: ${e.message ?: e.javaClass.simpleName}"
)
return
}
prompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
}
@JvmOverloads
fun finishActivity(
resultType: BiometryResultType = BiometryResultType.SUCCESS,
errorCode: Int = 0,
errorMessage: String? = ""
) {
val intent = Intent()
val prefix = BiometricPlugin.RESULT_EXTRA_PREFIX
intent
.putExtra(prefix + BiometricPlugin.RESULT_TYPE, resultType.toString())
.putExtra(prefix + BiometricPlugin.RESULT_ERROR_CODE, errorCode)
.putExtra(
prefix + BiometricPlugin.RESULT_ERROR_MESSAGE,
errorMessage
)
.putExtra(
prefix + BiometricPlugin.ENCRYPT_DECRYPT_OPERATION,
cipherOperation
)
if (resultType == BiometryResultType.SUCCESS && cipherOperation) {
// Decrypting data that was tampered with or encrypted with another key fails here.
val processedData = try {
processCipherData()
} catch (e: Exception) {
finishActivity(
BiometryResultType.ERROR,
0,
"Failed to ${if (cipherType == CipherType.ENCRYPT) "encrypt" else "decrypt"} the data: ${e.message ?: e.javaClass.simpleName}"
)
return
}
intent.putExtra(
prefix + BiometricPlugin.RESULT_ENCRYPT_DECRYPT_DATA,
processedData
)
}
setResult(Activity.RESULT_OK, intent)
finish()
}
/**
* Encrypts or decrypts [encryptDecryptData] with the cipher unlocked by the authentication.
* Encrypted data is returned as Base64 `data;iv`, decrypted data as a UTF-8 string.
*/
private fun processCipherData(): String {
val cryptoObject = requireNotNull(authenticationResult?.cryptoObject)
val cipher = requireNotNull(cryptoObject.cipher)
return if (cipherType == CipherType.ENCRYPT) {
encodeEncryptedData(cipher, cipher.doFinal(encryptDecryptData.toByteArray()))
} else {
val (_, encryptedData) = decodeEncryptedData(encryptDecryptData)
String(cipher.doFinal(encryptedData), Charset.forName("UTF-8"))
}
}
private fun createPromptInfo(): BiometricPrompt.PromptInfo? {
val builder = BiometricPrompt.PromptInfo.Builder()
val intent = intent
var title = intent.getStringExtra(BiometricPlugin.TITLE)
@@ -54,7 +162,7 @@ class BiometricActivity : AppCompatActivity() {
BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL,
getString(R.string.tauri_biometric_no_device_credential)
)
return
return null
}
if (title.isNullOrEmpty()) {
@@ -63,7 +171,11 @@ class BiometricActivity : AppCompatActivity() {
builder.setTitle(title).setSubtitle(subtitle).setDescription(description)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
var authenticators = BiometricManager.Authenticators.BIOMETRIC_WEAK
var authenticators = if (cipherOperation) {
BiometricManager.Authenticators.BIOMETRIC_STRONG
} else {
BiometricManager.Authenticators.BIOMETRIC_WEAK
}
if (allowDeviceCredential) {
authenticators = authenticators or BiometricManager.Authenticators.DEVICE_CREDENTIAL
}
@@ -86,50 +198,119 @@ class BiometricActivity : AppCompatActivity() {
builder.setConfirmationRequired(
intent.getBooleanExtra(BiometricPlugin.CONFIRMATION_REQUIRED, true)
)
val promptInfo = builder.build()
val prompt = BiometricPrompt(
this,
executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(
errorCode: Int,
errorMessage: CharSequence
) {
super.onAuthenticationError(errorCode, errorMessage)
finishActivity(
BiometryResultType.ERROR,
errorCode,
errorMessage.toString()
)
}
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult
) {
super.onAuthenticationSucceeded(result)
finishActivity()
}
}
)
prompt.authenticate(promptInfo)
return builder.build()
}
@JvmOverloads
fun finishActivity(
resultType: BiometryResultType = BiometryResultType.SUCCESS,
errorCode: Int = 0,
errorMessage: String? = ""
) {
val intent = Intent()
val prefix = BiometricPlugin.RESULT_EXTRA_PREFIX
intent
.putExtra(prefix + BiometricPlugin.RESULT_TYPE, resultType.toString())
.putExtra(prefix + BiometricPlugin.RESULT_ERROR_CODE, errorCode)
.putExtra(
prefix + BiometricPlugin.RESULT_ERROR_MESSAGE,
errorMessage
private fun createBiometricPrompt(): BiometricPrompt {
val executor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
this.mainExecutor
} else {
Executor { command: Runnable? ->
Handler(this.mainLooper).post(
command!!
)
}
}
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(
errorCode: Int,
errorMessage: CharSequence
) {
super.onAuthenticationError(errorCode, errorMessage)
finishActivity(
BiometryResultType.ERROR,
errorCode,
errorMessage.toString()
)
}
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult
) {
super.onAuthenticationSucceeded(result)
authenticationResult = result
finishActivity()
}
}
return BiometricPrompt(this, executor, callback)
}
/**
* Get the key if exists, or create a new one if not
*/
private fun getEncryptionKey(): SecretKey {
val keyStore = KeyStore.getInstance(KEYSTORE_NAME)
keyStore.load(null)
keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey }
// from here ahead, we will move only if there is not a key with the provided alias
val keyGenerator = KeyGenerator.getInstance(ENCRYPTION_ALGORITHM, KEYSTORE_NAME)
val builder = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
setResult(Activity.RESULT_OK, intent)
finish()
.setBlockModes(ENCRYPTION_BLOCK_MODE)
.setEncryptionPaddings(ENCRYPTION_PADDING)
.setKeySize(256)
.setRandomizedEncryptionRequired(true)
.setUserAuthenticationRequired(true) // Forces to use the biometric authentication to create/retrieve the key
keyGenerator.init(builder.build())
return keyGenerator.generateKey()
}
private fun deleteEncryptionKey() {
val keyStore = KeyStore.getInstance(KEYSTORE_NAME)
keyStore.load(null)
keyStore.deleteEntry(KEY_ALIAS)
}
private fun getCipher(): Cipher {
val biometricKey = getEncryptionKey()
val cipher = Cipher.getInstance("$ENCRYPTION_ALGORITHM/$ENCRYPTION_BLOCK_MODE/$ENCRYPTION_PADDING")
if (cipherType == CipherType.ENCRYPT) {
try {
cipher.init(Cipher.ENCRYPT_MODE, biometricKey)
} catch (e: KeyPermanentlyInvalidatedException) {
// The key was invalidated by a biometric enrollment change or the lock screen
// being removed. Whatever it encrypted can no longer be decrypted, so replace it.
deleteEncryptionKey()
cipher.init(Cipher.ENCRYPT_MODE, getEncryptionKey())
}
} else {
// Decodes the Base64 string to get the encrypted data and IV
val (iv, _) = decodeEncryptedData(encryptDecryptData)
cipher.init(Cipher.DECRYPT_MODE, biometricKey, GCMParameterSpec(128, iv))
}
return cipher
}
private fun encodeEncryptedData(cipher: Cipher, rawEncryptedData: ByteArray): String {
val encodedData = Base64.encodeToString(rawEncryptedData, Base64.NO_WRAP)
val encodedIv = Base64.encodeToString(cipher.iv, Base64.NO_WRAP)
val encodedString = encodedData + ";" + encodedIv
return encodedString
}
private fun decodeEncryptedData(encryptedDataEncoded: String): List<ByteArray> {
val (data, iv) = encryptedDataEncoded.split(";")
val decodedData = Base64.decode(data, Base64.NO_WRAP)
val decodedIv = Base64.decode(iv, Base64.NO_WRAP)
val ret = listOf(decodedIv, decodedData)
return ret
}
companion object {
var cipherOperation = false
var encryptDecryptData: String = ""
var authenticationResult: BiometricPrompt.AuthenticationResult? = null
var cipherType: CipherType = CipherType.ENCRYPT
}
}
@@ -28,6 +28,10 @@ enum class BiometryResultType {
SUCCESS, FAILURE, ERROR
}
enum class CipherType {
ENCRYPT, DECRYPT
}
private const val MAX_ATTEMPTS = "maxAttemps"
private const val BIOMETRIC_FAILURE = "authenticationFailed"
private const val INVALID_CONTEXT_ERROR = "invalidContext"
@@ -41,6 +45,8 @@ class AuthOptions {
var cancelTitle: String? = null
var confirmationRequired: Boolean? = null
var maxAttemps: Int = 3
var dataToEncrypt: String? = null
var dataToDecrypt: String? = null
}
@TauriPlugin
@@ -58,32 +64,36 @@ class BiometricPlugin(private val activity: Activity): Plugin(activity) {
const val RESULT_ERROR_MESSAGE = "errorMessage"
const val DEVICE_CREDENTIAL = "allowDeviceCredential"
const val CONFIRMATION_REQUIRED = "confirmationRequired"
const val ENCRYPT_DECRYPT_OPERATION = "cipherOperation"
const val ENCRYPT_DECRYPT_DATA = "encryptDecryptData"
const val CIPHER_OPERATION_TYPE = "cipherOperationType"
const val RESULT_ENCRYPT_DECRYPT_DATA = "resultEncryptDecryptData"
// Maps biometry error numbers to string error codes
private var biometryErrorCodeMap: MutableMap<Int, String> = HashMap()
private var biometryNameMap: MutableMap<BiometryType, String> = EnumMap(BiometryType::class.java)
init {
biometryErrorCodeMap[BiometricManager.BIOMETRIC_SUCCESS] = ""
biometryErrorCodeMap[BiometricManager.BIOMETRIC_SUCCESS] = ""
biometryErrorCodeMap[BiometricPrompt.ERROR_CANCELED] = "systemCancel"
biometryErrorCodeMap[BiometricPrompt.ERROR_HW_NOT_PRESENT] = "biometryNotAvailable"
biometryErrorCodeMap[BiometricPrompt.ERROR_HW_UNAVAILABLE] = "biometryNotAvailable"
biometryErrorCodeMap[BiometricPrompt.ERROR_LOCKOUT] = "biometryLockout"
biometryErrorCodeMap[BiometricPrompt.ERROR_LOCKOUT_PERMANENT] = "biometryLockout"
biometryErrorCodeMap[BiometricPrompt.ERROR_NEGATIVE_BUTTON] = "userCancel"
biometryErrorCodeMap[BiometricPrompt.ERROR_NO_BIOMETRICS] = "biometryNotEnrolled"
biometryErrorCodeMap[BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL] = "passcodeNotSet"
biometryErrorCodeMap[BiometricPrompt.ERROR_NO_SPACE] = "systemCancel"
biometryErrorCodeMap[BiometricPrompt.ERROR_TIMEOUT] = "systemCancel"
biometryErrorCodeMap[BiometricPrompt.ERROR_UNABLE_TO_PROCESS] = "systemCancel"
biometryErrorCodeMap[BiometricPrompt.ERROR_USER_CANCELED] = "userCancel"
biometryErrorCodeMap[BiometricPrompt.ERROR_VENDOR] = "systemCancel"
init {
biometryErrorCodeMap[BiometricManager.BIOMETRIC_SUCCESS] = ""
biometryErrorCodeMap[BiometricManager.BIOMETRIC_SUCCESS] = ""
biometryErrorCodeMap[BiometricPrompt.ERROR_CANCELED] = "systemCancel"
biometryErrorCodeMap[BiometricPrompt.ERROR_HW_NOT_PRESENT] = "biometryNotAvailable"
biometryErrorCodeMap[BiometricPrompt.ERROR_HW_UNAVAILABLE] = "biometryNotAvailable"
biometryErrorCodeMap[BiometricPrompt.ERROR_LOCKOUT] = "biometryLockout"
biometryErrorCodeMap[BiometricPrompt.ERROR_LOCKOUT_PERMANENT] = "biometryLockout"
biometryErrorCodeMap[BiometricPrompt.ERROR_NEGATIVE_BUTTON] = "userCancel"
biometryErrorCodeMap[BiometricPrompt.ERROR_NO_BIOMETRICS] = "biometryNotEnrolled"
biometryErrorCodeMap[BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL] = "passcodeNotSet"
biometryErrorCodeMap[BiometricPrompt.ERROR_NO_SPACE] = "systemCancel"
biometryErrorCodeMap[BiometricPrompt.ERROR_TIMEOUT] = "systemCancel"
biometryErrorCodeMap[BiometricPrompt.ERROR_UNABLE_TO_PROCESS] = "systemCancel"
biometryErrorCodeMap[BiometricPrompt.ERROR_USER_CANCELED] = "userCancel"
biometryErrorCodeMap[BiometricPrompt.ERROR_VENDOR] = "systemCancel"
biometryNameMap[BiometryType.NONE] = "No Authentication"
biometryNameMap[BiometryType.FINGERPRINT] = "Fingerprint Authentication"
biometryNameMap[BiometryType.FACE] = "Face Authentication"
biometryNameMap[BiometryType.IRIS] = "Iris Authentication"
biometryNameMap[BiometryType.NONE] = "No Authentication"
biometryNameMap[BiometryType.FINGERPRINT] = "Fingerprint Authentication"
biometryNameMap[BiometryType.FACE] = "Face Authentication"
biometryNameMap[BiometryType.IRIS] = "Iris Authentication"
}
}
@@ -157,18 +167,15 @@ class BiometricPlugin(private val activity: Activity): Plugin(activity) {
}
/**
* Prompt the user for biometric authentication.
* sets up the options for BiometricPrompt.
*/
@Command
fun authenticate(invoke: Invoke) {
private fun configBiometricPrompt(args: AuthOptions): Intent {
// The result of an intent is supposed to have the package name as a prefix
RESULT_EXTRA_PREFIX = activity.packageName + "."
val intent = Intent(
activity,
BiometricActivity::class.java
)
val args = invoke.parseArgs(AuthOptions::class.java)
// Pass the options to the activity
intent.putExtra(
@@ -186,9 +193,52 @@ class BiometricPlugin(private val activity: Activity): Plugin(activity) {
val maxAttemptsConfig = args.maxAttemps
val maxAttempts = max(maxAttemptsConfig, 1)
intent.putExtra(MAX_ATTEMPTS, maxAttempts)
return intent
}
/**
* Prompt the user for biometric authentication.
*/
@Command
fun authenticate(invoke: Invoke) {
val args: AuthOptions = invoke.parseArgs(AuthOptions::class.java)
val intent = configBiometricPrompt(args)
startActivityForResult(invoke, intent, "authenticateResult")
}
/**
* Prompt the user for biometric authentication to encrypt/decrypt some provided data]
*/
@Command
fun biometricCipher(invoke: Invoke) {
val args: AuthOptions = invoke.parseArgs(AuthOptions::class.java)
if (args.dataToEncrypt == null && args.dataToDecrypt == null) {
invoke.reject("Either dataToEncrypt or dataToDecrypt must be provided")
return
}
val intent = configBiometricPrompt(args)
var operationType: CipherType
val data = if (args.dataToEncrypt != null) {
operationType = CipherType.ENCRYPT
args.dataToEncrypt
} else {
operationType = CipherType.DECRYPT
args.dataToDecrypt
}
intent.putExtra(ENCRYPT_DECRYPT_DATA, data)
intent.putExtra(ENCRYPT_DECRYPT_OPERATION, true)
intent.putExtra(CIPHER_OPERATION_TYPE, operationType.ordinal)
startActivityForResult(invoke, intent, "authenticateResult")
}
@ActivityCallback
private fun authenticateResult(invoke: Invoke, result: ActivityResult) {
val resultCode = result.resultCode
@@ -231,8 +281,29 @@ class BiometricPlugin(private val activity: Activity): Plugin(activity) {
var errorMessage = data.getStringExtra(
RESULT_EXTRA_PREFIX + RESULT_ERROR_MESSAGE
)
val cipherOperation = data.getBooleanExtra(RESULT_EXTRA_PREFIX + ENCRYPT_DECRYPT_OPERATION, false)
var resolvedData = JSObject()
if (cipherOperation) {
val processedData = data.getStringExtra(
RESULT_EXTRA_PREFIX + RESULT_ENCRYPT_DECRYPT_DATA
)
resolvedData.put(
"data",
processedData
)
}
when (resultType) {
BiometryResultType.SUCCESS -> invoke.resolve()
BiometryResultType.SUCCESS -> {
if (cipherOperation) {
invoke.resolve(resolvedData)
} else {
invoke.resolve()
}
}
BiometryResultType.FAILURE -> // Biometry was successfully presented but was not recognized
invoke.reject(errorMessage, BIOMETRIC_FAILURE)