mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-10-18 23:07:27 +02:00
[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:
1 parent
b3e52ce036
commit
0374bec1e2
19 files changed
+915
-141
No files matched your search
+47
-10
@@ -1,14 +1,14 @@
|
||||

|
||||
|
||||
Prompt the user for biometric authentication on Android, iOS and macOS.
|
||||
Prompt the user for biometric authentication on Android, iOS and macOS. On Android, it also allows encrypting and decrypting data with keys protected by biometric authentication.
|
||||
|
||||
| Platform | Supported |
|
||||
| -------- | --------- |
|
||||
| Linux | x |
|
||||
| Windows | x |
|
||||
| macOS | ✓ |
|
||||
| Android | ✓ |
|
||||
| iOS | ✓ |
|
||||
| Platform | Supported |
|
||||
| -------- | ----------------------------- |
|
||||
| Linux | x |
|
||||
| Windows | x |
|
||||
| macOS | ✓ |
|
||||
| Android | ✓ |
|
||||
| iOS | ✓ (no biometric cryptography) |
|
||||
|
||||
## Install
|
||||
|
||||
@@ -61,8 +61,45 @@ fn main() {
|
||||
Afterwards all the plugin's APIs are available through the JavaScript guest bindings:
|
||||
|
||||
```javascript
|
||||
import { authenticate } from '@tauri-apps/plugin-biometric'
|
||||
await authenticate('Open your wallet')
|
||||
import {
|
||||
checkStatus,
|
||||
authenticate,
|
||||
biometricCipher
|
||||
} from '@tauri-apps/plugin-biometric'
|
||||
|
||||
const status = await checkStatus()
|
||||
if (status.isAvailable) {
|
||||
await authenticate('Open your wallet')
|
||||
}
|
||||
|
||||
// ... or using biometric-protected cryptography:
|
||||
if (status.isAvailable) {
|
||||
const encryptOptions = {
|
||||
dataToEncrypt: getData()
|
||||
}
|
||||
const encrypted = await biometricCipher(
|
||||
'Login without typing password',
|
||||
encryptOptions
|
||||
)
|
||||
|
||||
const decryptOptions = {
|
||||
dataToDecrypt: encrypted.data
|
||||
}
|
||||
const originalData = await biometricCipher(
|
||||
'Login without typing password',
|
||||
decryptOptions
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Permissions
|
||||
|
||||
The default permission set (`biometric:default`) only allows `status` and `authenticate`. To use `biometricCipher`, add the `biometric:allow-biometric-cipher` permission to your capability:
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": ["biometric:default", "biometric:allow-biometric-cipher"]
|
||||
}
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
if("__TAURI__"in window){var __TAURI_PLUGIN_BIOMETRIC__=function(e,i){"use strict";var t;return e.BiometryType=void 0,(t=e.BiometryType||(e.BiometryType={}))[t.None=0]="None",t[t.TouchID=1]="TouchID",t[t.FaceID=2]="FaceID",t[t.Iris=3]="Iris",e.authenticate=async function(e,t){await i.invoke("plugin:biometric|authenticate",{reason:e,...t})},e.checkStatus=async function(){return await i.invoke("plugin:biometric|status")},e}({},window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"biometric",{value:__TAURI_PLUGIN_BIOMETRIC__})}
|
||||
if("__TAURI__"in window){var __TAURI_PLUGIN_BIOMETRIC__=function(i,e){"use strict";var t;return i.BiometryType=void 0,(t=i.BiometryType||(i.BiometryType={}))[t.None=0]="None",t[t.TouchID=1]="TouchID",t[t.FaceID=2]="FaceID",t[t.Iris=3]="Iris",i.authenticate=async function(i,t){await e.invoke("plugin:biometric|authenticate",{reason:i,...t})},i.biometricCipher=async function(i,t){return await e.invoke("plugin:biometric|biometric_cipher",{reason:i,...t})},i.checkStatus=async function(){return await e.invoke("plugin:biometric|status")},i}({},window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"biometric",{value:__TAURI_PLUGIN_BIOMETRIC__})}
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const COMMANDS: &[&str] = &["authenticate", "status"];
|
||||
const COMMANDS: &[&str] = &["authenticate", "status", "biometric_cipher"];
|
||||
|
||||
fn main() {
|
||||
let result = tauri_plugin::Builder::new(COMMANDS)
|
||||
|
||||
@@ -58,6 +58,10 @@ export interface AuthOptions {
|
||||
allowDeviceCredential?: boolean
|
||||
/** Label for the cancel button. Available on Android, iOS and macOS. */
|
||||
cancelTitle?: string
|
||||
/** The plain data that must be encrypted after successful biometric authentication. Used by {@linkcode biometricCipher}. **Android only.** */
|
||||
dataToEncrypt?: string
|
||||
/** The encrypted data that must be decrypted after successful biometric authentication. Used by {@linkcode biometricCipher}. **Android only.** */
|
||||
dataToDecrypt?: string
|
||||
|
||||
// iOS and macOS options
|
||||
/** Text displayed on the fallback button if biometric authentication fails. **iOS and macOS only.** */
|
||||
@@ -115,3 +119,41 @@ export async function authenticate(
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts/Decrypts some payload using biometric authentication. It will Prompt the user to authenticate
|
||||
* using the system interface (touchID, faceID or Android Iris).
|
||||
* Rejects if the authentication fails.
|
||||
*
|
||||
* Warning: consider that if the data is encrypted and the user changes the biometric settings, the key needed
|
||||
* to decrypt the data will be lost. So, it's recommended to use this only to data that the user can get back
|
||||
* by other means (i.e. storing their password in a secure way so they can login passwordless with biometric authentication).
|
||||
*
|
||||
* ```javascript
|
||||
* import { biometricCipher } from "@tauri-apps/plugin-biometric";
|
||||
*
|
||||
* // how to encrypt some data
|
||||
* const options = {
|
||||
* dataToEncrypt: "...", // if not empty, will encrypt this data
|
||||
* };
|
||||
* const encryptedData = await biometricCipher('Open your wallet', options);
|
||||
*
|
||||
* // how to decrypt the encrypted data
|
||||
* const options = {
|
||||
* dataToDecrypt: encryptedData // if not empty, will decrypt the data (that was previously encrypted by this method)
|
||||
* };
|
||||
* const decryptedData = await biometricCipher('Open your wallet', options);
|
||||
* ```
|
||||
* @param reason
|
||||
* @param options
|
||||
* @returns
|
||||
*/
|
||||
export async function biometricCipher(
|
||||
reason: string,
|
||||
options?: AuthOptions
|
||||
): Promise<{ data: string }> {
|
||||
return await invoke<{ data: string }>('plugin:biometric|biometric_cipher', {
|
||||
reason,
|
||||
...options
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-biometric-cipher"
|
||||
description = "Enables the biometric_cipher command without any pre-configured scope."
|
||||
commands.allow = ["biometric_cipher"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-biometric-cipher"
|
||||
description = "Denies the biometric_cipher command without any pre-configured scope."
|
||||
commands.deny = ["biometric_cipher"]
|
||||
@@ -5,7 +5,11 @@ biometric features are by default exposed.
|
||||
|
||||
#### Granted Permissions
|
||||
|
||||
It allows acccess to all biometric commands.
|
||||
It allows checking the biometric status and
|
||||
prompting for authentication. Encrypting and
|
||||
decrypting data with `biometric_cipher` is not
|
||||
included and must be enabled explicitly with
|
||||
`biometric:allow-biometric-cipher`.
|
||||
|
||||
#### This default permission set includes the following:
|
||||
|
||||
@@ -50,6 +54,32 @@ Denies the authenticate command without any pre-configured scope.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`biometric:allow-biometric-cipher`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the biometric_cipher command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`biometric:deny-biometric-cipher`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the biometric_cipher command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`biometric:allow-status`
|
||||
|
||||
</td>
|
||||
|
||||
@@ -6,7 +6,11 @@ biometric features are by default exposed.
|
||||
|
||||
#### Granted Permissions
|
||||
|
||||
It allows acccess to all biometric commands.
|
||||
It allows checking the biometric status and
|
||||
prompting for authentication. Encrypting and
|
||||
decrypting data with `biometric_cipher` is not
|
||||
included and must be enabled explicitly with
|
||||
`biometric:allow-biometric-cipher`.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
@@ -306,6 +306,18 @@
|
||||
"const": "deny-authenticate",
|
||||
"markdownDescription": "Denies the authenticate command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the biometric_cipher command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-biometric-cipher",
|
||||
"markdownDescription": "Enables the biometric_cipher command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the biometric_cipher command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-biometric-cipher",
|
||||
"markdownDescription": "Denies the biometric_cipher command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the status command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -319,10 +331,10 @@
|
||||
"markdownDescription": "Denies the status command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nbiometric features are by default exposed.\n\n#### Granted Permissions\n\nIt allows acccess to all biometric commands.\n\n\n#### This default permission set includes:\n\n- `allow-authenticate`\n- `allow-status`",
|
||||
"description": "This permission set configures which\nbiometric features are by default exposed.\n\n#### Granted Permissions\n\nIt allows checking the biometric status and\nprompting for authentication. Encrypting and\ndecrypting data with `biometric_cipher` is not\nincluded and must be enabled explicitly with\n`biometric:allow-biometric-cipher`.\n\n\n#### This default permission set includes:\n\n- `allow-authenticate`\n- `allow-status`",
|
||||
"type": "string",
|
||||
"const": "default",
|
||||
"markdownDescription": "This permission set configures which\nbiometric features are by default exposed.\n\n#### Granted Permissions\n\nIt allows acccess to all biometric commands.\n\n\n#### This default permission set includes:\n\n- `allow-authenticate`\n- `allow-status`"
|
||||
"markdownDescription": "This permission set configures which\nbiometric features are by default exposed.\n\n#### Granted Permissions\n\nIt allows checking the biometric status and\nprompting for authentication. Encrypting and\ndecrypting data with `biometric_cipher` is not\nincluded and must be enabled explicitly with\n`biometric:allow-biometric-cipher`.\n\n\n#### This default permission set includes:\n\n- `allow-authenticate`\n- `allow-status`"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ pub(crate) async fn authenticate<R: Runtime>(
|
||||
title,
|
||||
subtitle,
|
||||
confirmation_required,
|
||||
..Default::default()
|
||||
};
|
||||
// The prompt blocks until the user answers, so keep it off the async runtime workers.
|
||||
tauri::async_runtime::spawn_blocking(move || app.biometric().authenticate(reason, options))
|
||||
|
||||
@@ -53,4 +53,17 @@ impl<R: Runtime> Biometric<R> {
|
||||
.run_mobile_plugin("authenticate", AuthenticatePayload { reason, options })
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Prompts the user for biometric authentication and, on success, encrypts
|
||||
/// [`AuthOptions::data_to_encrypt`] or decrypts [`AuthOptions::data_to_decrypt`] using a key
|
||||
/// protected by the device's strong biometrics. **Android only.**
|
||||
pub fn biometric_cipher(
|
||||
&self,
|
||||
reason: String,
|
||||
options: AuthOptions,
|
||||
) -> crate::Result<CipherResult> {
|
||||
self.0
|
||||
.run_mobile_plugin("biometricCipher", AuthenticatePayload { reason, options })
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@ pub struct AuthOptions {
|
||||
pub allow_device_credential: bool,
|
||||
/// Label for the Cancel button. This feature is available on Android, iOS and macOS.
|
||||
pub cancel_title: Option<String>,
|
||||
/// The plain data that must be encrypted after successful biometric authentication. **Android only.**
|
||||
pub data_to_encrypt: Option<String>,
|
||||
/// The encrypted data that must be decrypted after successful biometric authentication. **Android only.**
|
||||
pub data_to_decrypt: Option<String>,
|
||||
/// Specifies the text displayed on the fallback button if biometric authentication fails. This feature is available on iOS and macOS only.
|
||||
pub fallback_title: Option<String>,
|
||||
/// Title indicating the purpose of biometric verification. This feature is available Android only.
|
||||
@@ -50,3 +54,10 @@ pub struct Status {
|
||||
/// Only set when [`Self::is_available`] is `false`.
|
||||
pub error_code: Option<String>,
|
||||
}
|
||||
|
||||
/// The result of [`Biometric::biometric_cipher`](crate::Biometric::biometric_cipher).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CipherResult {
|
||||
/// The encrypted or decrypted data.
|
||||
pub data: String,
|
||||
}
|
||||
Reference in new issue
Block a user