Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .changes/biometric-android-encrypt-decrypt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"biometric": minor
"biometric-js": minor
---

Update `biometric` from `2.3.0` to `2.4.0` to support encrypting and decrypting data using android keystore requiring biometric login.
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion plugins/biometric/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "tauri-plugin-biometric"
version = "2.3.0"
version = "2.4.0"
description = "Prompt the user for biometric authentication on Android and iOS."
edition = { workspace = true }
authors = { workspace = true }
Expand Down
32 changes: 32 additions & 0 deletions plugins/biometric/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,38 @@ import { authenticate } from '@tauri-apps/plugin-biometric'
await authenticate('Open your wallet')
```

To encrypt:

```javascript
import { authenticate, AuthMode, type EncryptedCipherData } from '@tauri-apps/plugin-biometric';
let encryptedData = await authenticate('Encrypt your data', {
allowDeviceCredential: false,
cancelTitle: "Cancel",
fallbackTitle: 'Sorry, authentication failed',
title: 'Authentication',
confirmationRequired: true,
mode: AuthMode.ENCRYPT,
cipherKey: "YourKey",
cipherData: {
data: "Your secret data",
},
}) as EncryptedCipherData;
```

Then to decrypt it:
```javascript
let decryptedData = await authenticate('Decrypt your data', {
allowDeviceCredential: false,
cancelTitle: "Cancel",
fallbackTitle: 'Sorry, authentication failed',
title: 'Authentication',
confirmationRequired: true,
mode: AuthMode.DECRYPT,
cipherKey: "YourKey",
cipherData: encryptedData,
});
```

## Contributing

PRs accepted. Please make sure to read the Contributing Guide before making a pull request.
Expand Down
65 changes: 62 additions & 3 deletions plugins/biometric/android/src/main/java/BiometricActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,20 @@ import android.os.Bundle
import android.os.Handler
import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricPrompt
import androidx.biometric.BiometricPrompt.CryptoObject
import java.util.concurrent.Executor
import javax.crypto.Cipher
import java.util.Base64

class BiometricActivity : AppCompatActivity() {
private lateinit var cryptographyManager: CryptographyManager

@SuppressLint("WrongConstant")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

cryptographyManager = CryptographyManager()

setContentView(R.layout.auth_activity)

val executor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
Expand All @@ -38,6 +46,10 @@ class BiometricActivity : AppCompatActivity() {
var title = intent.getStringExtra(BiometricPlugin.TITLE)
val subtitle = intent.getStringExtra(BiometricPlugin.SUBTITLE)
val description = intent.getStringExtra(BiometricPlugin.REASON)
val mode = AuthMode.valueOf(intent.getStringExtra(BiometricPlugin.MODE) ?: "PROMPT")
val cipherKey = intent.getStringExtra(BiometricPlugin.CIPHER_KEY)
val cipherDataData = intent.getStringExtra(BiometricPlugin.CIPHER_DATA_DATA)
val cipherDataIV = intent.getStringExtra(BiometricPlugin.CIPHER_DATA_INITIALIZATION_VECTOR)
allowDeviceCredential = false
// Android docs say we should check if the device is secure before enabling device credential fallback
val manager = getSystemService(
Expand All @@ -54,7 +66,7 @@ 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 = BiometricManager.Authenticators.BIOMETRIC_STRONG
if (allowDeviceCredential) {
authenticators = authenticators or BiometricManager.Authenticators.DEVICE_CREDENTIAL
}
Expand Down Expand Up @@ -87,6 +99,7 @@ class BiometricActivity : AppCompatActivity() {
) {
super.onAuthenticationError(errorCode, errorMessage)
finishActivity(
null,
BiometryResultType.ERROR,
errorCode,
errorMessage as String
Expand All @@ -97,15 +110,56 @@ class BiometricActivity : AppCompatActivity() {
result: BiometricPrompt.AuthenticationResult
) {
super.onAuthenticationSucceeded(result)
finishActivity()

finishActivity(processData(mode, cipherDataData, result.cryptoObject))
}
}
)
prompt.authenticate(promptInfo)

if (mode == AuthMode.ENCRYPT) {
val cipher = cryptographyManager.getInitializedCipherForEncryption(cipherKey!!)
prompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
} else if (mode == AuthMode.DECRYPT) {
val cipher = cryptographyManager.getInitializedCipherForDecryption(
cipherKey!!,
Base64.getDecoder().decode(cipherDataIV))
prompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
} else {
prompt.authenticate(promptInfo)
}
}

private fun processData(mode: AuthMode, cipherDataData: String?, cryptoObject: BiometricPrompt.CryptoObject?): CipherData? {
if (mode == AuthMode.ENCRYPT) {
if(cipherDataData == null) {
throw IllegalStateException("No data to encrypt")
}
val encryptedData = cryptographyManager.encryptData(cipherDataData, cryptoObject?.cipher!!)

return CipherData(
Base64.getEncoder().encodeToString(encryptedData.ciphertext),
Base64.getEncoder().encodeToString(encryptedData.initializationVector)
)
} else if (mode == AuthMode.DECRYPT) {
if(cipherDataData == null) {
throw IllegalStateException("No data to decrypt")
}
val decryptedData = cryptographyManager.decryptData(
Base64.getDecoder().decode(cipherDataData),
cryptoObject?.cipher!!)

return CipherData(
decryptedData,
null
)
}

return null
}

@JvmOverloads
fun finishActivity(
cipherData: CipherData?,
resultType: BiometryResultType = BiometryResultType.SUCCESS,
errorCode: Int = 0,
errorMessage: String? = ""
Expand All @@ -119,6 +173,11 @@ class BiometricActivity : AppCompatActivity() {
prefix + BiometricPlugin.RESULT_ERROR_MESSAGE,
errorMessage
)
if (cipherData != null) {
intent
.putExtra(prefix + BiometricPlugin.RESULT_DATA, cipherData.data)
.putExtra(prefix + BiometricPlugin.RESULT_IV, cipherData.initializationVector)
}
setResult(Activity.RESULT_OK, intent)
finish()
}
Expand Down
67 changes: 66 additions & 1 deletion plugins/biometric/android/src/main/java/BiometricPlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ enum class BiometryResultType {
SUCCESS, FAILURE, ERROR
}

enum class AuthMode {
PROMPT, ENCRYPT, DECRYPT
}

private const val MAX_ATTEMPTS = "maxAttemps"
private const val BIOMETRIC_FAILURE = "authenticationFailed"
private const val INVALID_CONTEXT_ERROR = "invalidContext"
Expand All @@ -41,18 +45,34 @@ class AuthOptions {
var cancelTitle: String? = null
var confirmationRequired: Boolean? = null
var maxAttemps: Int = 3
var mode: AuthMode = AuthMode.PROMPT
var cipherKey: String? = null
var cipherData: CipherData? = null
}

@InvokeArg
class CipherData (
var data: String? = null,
var initializationVector: String? = null
)

@TauriPlugin
class BiometricPlugin(private val activity: Activity): Plugin(activity) {
private var biometryTypes: ArrayList<BiometryType> = arrayListOf()
private lateinit var mode: AuthMode

companion object {
var RESULT_EXTRA_PREFIX = ""
const val MODE = "mode"
const val CIPHER_KEY = "cipherKey"
const val CIPHER_DATA_DATA = "cipherDataData"
const val CIPHER_DATA_INITIALIZATION_VECTOR = "cipherDataInitializationVector"
const val TITLE = "title"
const val SUBTITLE = "subtitle"
const val REASON = "reason"
const val CANCEL_TITLE = "cancelTitle"
const val RESULT_DATA = "data"
const val RESULT_IV = "initializationVector"
const val RESULT_TYPE = "type"
const val RESULT_ERROR_CODE = "errorCode"
const val RESULT_ERROR_MESSAGE = "errorMessage"
Expand Down Expand Up @@ -183,6 +203,32 @@ class BiometricPlugin(private val activity: Activity): Plugin(activity) {
intent.putExtra(CONFIRMATION_REQUIRED, it)
}

mode = (args.mode as AuthMode) ?: AuthMode.PROMPT
intent.putExtra(MODE, mode.name)
if (mode != AuthMode.PROMPT) {
if(args.cipherData == null || args.cipherKey == null) {
invoke.reject(
"Cipher key and cipher data must be provided for ENCRYPT and DECRYPT modes",
biometryErrorCodeMap[BiometricPrompt.ERROR_CANCELED]
)
return
}
if(mode == AuthMode.DECRYPT && args.cipherData?.initializationVector == null) {
invoke.reject(
"Initialization Vector must be provided for DECRYPT mode",
biometryErrorCodeMap[BiometricPrompt.ERROR_CANCELED]
)
return
}

intent.putExtra(CIPHER_KEY, args.cipherKey)

val cipherDataData = args.cipherData?.data
val cipherDataIV = args.cipherData?.initializationVector
intent.putExtra(CIPHER_DATA_DATA, cipherDataData)
intent.putExtra(CIPHER_DATA_INITIALIZATION_VECTOR, cipherDataIV)
}

val maxAttemptsConfig = args.maxAttemps
val maxAttempts = max(maxAttemptsConfig, 1)
intent.putExtra(MAX_ATTEMPTS, maxAttempts)
Expand All @@ -205,6 +251,13 @@ class BiometricPlugin(private val activity: Activity): Plugin(activity) {

// Convert the string result type to an enum
val data = result.data
val resultData = data?.getStringExtra(
RESULT_EXTRA_PREFIX + RESULT_DATA
)
val resultIV = data?.getStringExtra(
RESULT_EXTRA_PREFIX + RESULT_IV
)

val resultTypeName = data?.getStringExtra(
RESULT_EXTRA_PREFIX + RESULT_TYPE
)
Expand Down Expand Up @@ -232,7 +285,19 @@ class BiometricPlugin(private val activity: Activity): Plugin(activity) {
RESULT_EXTRA_PREFIX + RESULT_ERROR_MESSAGE
)
when (resultType) {
BiometryResultType.SUCCESS -> invoke.resolve()
BiometryResultType.SUCCESS -> {
when(mode) {
AuthMode.ENCRYPT, AuthMode.DECRYPT -> {
val ret = JSObject()

ret.put("data", resultData)
ret.put("initializationVector", resultIV)

invoke.resolve(ret)
}
else -> invoke.resolve()
}
}
BiometryResultType.FAILURE -> // Biometry was successfully presented but was not recognized
invoke.reject(errorMessage, BIOMETRIC_FAILURE)

Expand Down
100 changes: 100 additions & 0 deletions plugins/biometric/android/src/main/java/CryptographyManager.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package app.tauri.biometric

import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import java.security.KeyStore
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.nio.charset.Charset

interface CryptographyManager {
/**
* This method first gets or generates an instance of SecretKey and then initializes the Cipher
* with the key. The secret key uses [ENCRYPT_MODE][Cipher.ENCRYPT_MODE] is used.
*/
fun getInitializedCipherForEncryption(keyName: String): Cipher

/**
* This method first gets or generates an instance of SecretKey and then initializes the Cipher
* with the key. The secret key uses [DECRYPT_MODE][Cipher.DECRYPT_MODE] is used.
*/
fun getInitializedCipherForDecryption(keyName: String, initializationVector: ByteArray): Cipher

/**
* The Cipher created with [getInitializedCipherForEncryption] is used here
*/
fun encryptData(plaintext: String, cipher: Cipher): EncryptedData

/**
* The Cipher created with [getInitializedCipherForDecryption] is used here
*/
fun decryptData(ciphertext: ByteArray, cipher: Cipher): String

}

fun CryptographyManager(): CryptographyManager = CryptographyManagerImpl()

data class EncryptedData(val ciphertext: ByteArray, val initializationVector: ByteArray)

private class CryptographyManagerImpl : CryptographyManager {

private val KEY_SIZE: Int = 256
val ANDROID_KEYSTORE = "AndroidKeyStore"
private val ENCRYPTION_BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM
private val ENCRYPTION_PADDING = KeyProperties.ENCRYPTION_PADDING_NONE
private val ENCRYPTION_ALGORITHM = KeyProperties.KEY_ALGORITHM_AES

override fun getInitializedCipherForEncryption(keyName: String): Cipher {
val cipher = getCipher()
val secretKey = getOrCreateSecretKey(keyName)
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
return cipher
}

override fun getInitializedCipherForDecryption(keyName: String, initializationVector: ByteArray): Cipher {
val cipher = getCipher()
val secretKey = getOrCreateSecretKey(keyName)
cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, initializationVector))
return cipher
}

override fun encryptData(plaintext: String, cipher: Cipher): EncryptedData {
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charset.forName("UTF-8")))
return EncryptedData(ciphertext,cipher.iv)
}

override fun decryptData(ciphertext: ByteArray, cipher: Cipher): String {
val plaintext = cipher.doFinal(ciphertext)
return String(plaintext, Charset.forName("UTF-8"))
}

private fun getCipher(): Cipher {
val transformation = "$ENCRYPTION_ALGORITHM/$ENCRYPTION_BLOCK_MODE/$ENCRYPTION_PADDING"
return Cipher.getInstance(transformation)
}

private fun getOrCreateSecretKey(keyName: String): SecretKey {
// If Secretkey was previously created for that keyName, then grab and return it.
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
keyStore.load(null) // Keystore must be loaded before it can be accessed
keyStore.getKey(keyName, null)?.let { return it as SecretKey }

// if you reach here, then a new SecretKey must be generated for that keyName
val paramsBuilder = KeyGenParameterSpec.Builder(keyName,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
paramsBuilder.apply {
setBlockModes(ENCRYPTION_BLOCK_MODE)
setEncryptionPaddings(ENCRYPTION_PADDING)
setKeySize(KEY_SIZE)
setUserAuthenticationRequired(true)
}

val keyGenParams = paramsBuilder.build()
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES,
ANDROID_KEYSTORE)
keyGenerator.init(keyGenParams)
return keyGenerator.generateKey()
}
}
Loading