From 779c7cd8f6355519132ed7aa97e1e7c0a742cef9 Mon Sep 17 00:00:00 2001 From: Charles Schaefer Date: Wed, 12 Feb 2025 06:15:45 -0300 Subject: [PATCH 1/6] =?UTF-8?q?=C2=96feat:=20adding=20the=20ability=20to?= =?UTF-8?q?=20encrypt/decrypt=20data=20based=20on=20the=20biometric=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/main/java/BiometricActivity.kt | 245 ++++++++++++++---- .../android/src/main/java/BiometricPlugin.kt | 120 +++++++-- plugins/biometric/build.rs | 2 +- plugins/biometric/guest-js/index.ts | 48 ++++ .../commands/biometric_cipher.toml | 13 + .../permissions/autogenerated/reference.md | 26 ++ .../biometric/permissions/schemas/schema.json | 10 + plugins/biometric/src/lib.rs | 7 + plugins/biometric/src/models.rs | 9 + 9 files changed, 404 insertions(+), 76 deletions(-) create mode 100644 plugins/biometric/permissions/autogenerated/commands/biometric_cipher.toml diff --git a/plugins/biometric/android/src/main/java/BiometricActivity.kt b/plugins/biometric/android/src/main/java/BiometricActivity.kt index 011de4d5..4a4a68ce 100644 --- a/plugins/biometric/android/src/main/java/BiometricActivity.kt +++ b/plugins/biometric/android/src/main/java/BiometricActivity.kt @@ -16,29 +16,120 @@ 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.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!! + val promptInfo = createPromptInfo() + val prompt = createBiometricPrompt() + + cipherOperation = intent.hasExtra(BiometricPlugin.ENCRYPT_DECRYPT_OPERATION) + 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 + } + + val cipher = getCipher() + 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 (cipherOperation) { + val cryptoObject = requireNotNull(authenticationResult?.cryptoObject) + val cipher = requireNotNull(cryptoObject.cipher) + var dataToProcess = if (cipherType == CipherType.ENCRYPT) { + encryptDecryptData.toByteArray() + } else { + val (_, encryptedData) = decodeEncryptedData(encryptDecryptData) + encryptedData + } + var processedData: ByteArray = cipher.doFinal(dataToProcess) + + if (cipherType == CipherType.ENCRYPT) { + // Converts the encrypted data to Base64 string + val encodedString = encodeEncryptedData(cipher, processedData) + intent.putExtra( + prefix + BiometricPlugin.RESULT_ENCRYPT_DECRYPT_DATA, + encodedString + ) + } else { + // For decryption, return the decrypted string + intent.putExtra( + prefix + BiometricPlugin.RESULT_ENCRYPT_DECRYPT_DATA, + String(processedData, Charset.forName("UTF-8")) ) } } + setResult(Activity.RESULT_OK, intent) + finish() + } + private fun createPromptInfo(): BiometricPrompt.PromptInfo { val builder = BiometricPrompt.PromptInfo.Builder() val intent = intent var title = intent.getStringExtra(BiometricPlugin.TITLE) val subtitle = intent.getStringExtra(BiometricPlugin.SUBTITLE) val description = intent.getStringExtra(BiometricPlugin.REASON) + allowDeviceCredential = false + cipherOperation = intent.hasExtra(BiometricPlugin.ENCRYPT_DECRYPT_OPERATION) + // Android docs say we should check if the device is secure before enabling device credential fallback val manager = getSystemService( Context.KEYGUARD_SERVICE @@ -54,7 +145,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 } @@ -76,54 +171,108 @@ 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 as String - ) - } - - override fun onAuthenticationSucceeded( - result: BiometricPrompt.AuthenticationResult - ) { - super.onAuthenticationSucceeded(result) - finishActivity() - } + + return builder.build() + } + + 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!! + ) } - ) - prompt.authenticate(promptInfo) + } + + val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError( + errorCode: Int, + errorMessage: CharSequence + ) { + super.onAuthenticationError(errorCode, errorMessage) + finishActivity( + BiometryResultType.ERROR, + errorCode, + errorMessage as String + ) + } + + override fun onAuthenticationSucceeded( + result: BiometricPrompt.AuthenticationResult + ) { + super.onAuthenticationSucceeded(result) + authenticationResult = result + finishActivity() + } + } + + return BiometricPrompt(this, executor, callback) } - @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 + /** + * 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 getCipher(): Cipher { + val biometricKey = getEncryptionKey() + + val cipher = Cipher.getInstance("$ENCRYPTION_ALGORITHM/$ENCRYPTION_BLOCK_MODE/$ENCRYPTION_PADDING") + + if (cipherType == CipherType.ENCRYPT) { + cipher.init(Cipher.ENCRYPT_MODE, biometricKey) + } 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 { + 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 allowDeviceCredential = false + var cipherOperation = false + var encryptDecryptData: String = "" + var authenticationResult: BiometricPrompt.AuthenticationResult? = null + var cipherType: CipherType = CipherType.ENCRYPT } } \ No newline at end of file diff --git a/plugins/biometric/android/src/main/java/BiometricPlugin.kt b/plugins/biometric/android/src/main/java/BiometricPlugin.kt index b3436fd4..0ccd5a36 100644 --- a/plugins/biometric/android/src/main/java/BiometricPlugin.kt +++ b/plugins/biometric/android/src/main/java/BiometricPlugin.kt @@ -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 = HashMap() private var biometryNameMap: MutableMap = 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] = "noDeviceCredential" - 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" + 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] = "noDeviceCredential" + 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" } } @@ -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,47 @@ 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) + + 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 +276,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) diff --git a/plugins/biometric/build.rs b/plugins/biometric/build.rs index 070986b2..d9d73692 100644 --- a/plugins/biometric/build.rs +++ b/plugins/biometric/build.rs @@ -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) diff --git a/plugins/biometric/guest-js/index.ts b/plugins/biometric/guest-js/index.ts index 5c3eb8df..a88ea490 100644 --- a/plugins/biometric/guest-js/index.ts +++ b/plugins/biometric/guest-js/index.ts @@ -33,15 +33,26 @@ export interface Status { } export interface AuthOptions { + /** Enables authentication using the device's password. This feature is available on both Android and iOS. */ allowDeviceCredential?: boolean + /** Label for the Cancel button. This feature is available on both Android and iOS. */ cancelTitle?: string + /** The plain data that must be encrypted after successfull biometric authentication */ + dataToEncrypt?: string + /** The encrypted data that must be decrypted after successfull biometric authentication */ + dataToDecrypt?: string + // iOS options + /** Specifies the text displayed on the fallback button if biometric authentication fails. This feature is available iOS only. */ fallbackTitle?: string // android options + /** Title indicating the purpose of biometric verification. This feature is available Android only. */ title?: string + /** SubTitle providing contextual information of biometric verification. This feature is available Android only. */ subtitle?: string + /** Specifies whether additional user confirmation is required, such as pressing a button after successful biometric authentication. This feature is available Android only. */ confirmationRequired?: boolean maxAttemps?: number } @@ -75,3 +86,40 @@ 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. + * + * + * ```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 + }); +} + diff --git a/plugins/biometric/permissions/autogenerated/commands/biometric_cipher.toml b/plugins/biometric/permissions/autogenerated/commands/biometric_cipher.toml new file mode 100644 index 00000000..22497255 --- /dev/null +++ b/plugins/biometric/permissions/autogenerated/commands/biometric_cipher.toml @@ -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"] diff --git a/plugins/biometric/permissions/autogenerated/reference.md b/plugins/biometric/permissions/autogenerated/reference.md index 37a9fa07..bba4563a 100644 --- a/plugins/biometric/permissions/autogenerated/reference.md +++ b/plugins/biometric/permissions/autogenerated/reference.md @@ -50,6 +50,32 @@ Denies the authenticate command without any pre-configured scope. +`biometric:allow-biometric-cipher` + + + + +Enables the biometric_cipher command without any pre-configured scope. + + + + + + + +`biometric:deny-biometric-cipher` + + + + +Denies the biometric_cipher command without any pre-configured scope. + + + + + + + `biometric:allow-status` diff --git a/plugins/biometric/permissions/schemas/schema.json b/plugins/biometric/permissions/schemas/schema.json index cc4d04d5..6eeaa655 100644 --- a/plugins/biometric/permissions/schemas/schema.json +++ b/plugins/biometric/permissions/schemas/schema.json @@ -304,6 +304,16 @@ "type": "string", "const": "deny-authenticate" }, + { + "description": "Enables the biometric_cipher command without any pre-configured scope.", + "type": "string", + "const": "allow-biometric-cipher" + }, + { + "description": "Denies the biometric_cipher command without any pre-configured scope.", + "type": "string", + "const": "deny-biometric-cipher" + }, { "description": "Enables the status command without any pre-configured scope.", "type": "string", diff --git a/plugins/biometric/src/lib.rs b/plugins/biometric/src/lib.rs index f79a104d..9170d6cd 100644 --- a/plugins/biometric/src/lib.rs +++ b/plugins/biometric/src/lib.rs @@ -23,6 +23,7 @@ const PLUGIN_IDENTIFIER: &str = "app.tauri.biometric"; #[cfg(target_os = "ios")] tauri::ios_plugin_binding!(init_plugin_biometric); + /// Access to the biometric APIs. pub struct Biometric(PluginHandle); @@ -43,6 +44,12 @@ impl Biometric { .run_mobile_plugin("authenticate", AuthenticatePayload { reason, options }) .map_err(Into::into) } + + pub fn biometric_cipher(&self, reason: String, options: AuthOptions) -> crate::Result { + self.0 + .run_mobile_plugin("biometricCipher", AuthenticatePayload { reason, options }) + .map_err(Into::into) + } } /// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the biometric APIs. diff --git a/plugins/biometric/src/models.rs b/plugins/biometric/src/models.rs index 49c84300..1589fa81 100644 --- a/plugins/biometric/src/models.rs +++ b/plugins/biometric/src/models.rs @@ -11,6 +11,10 @@ pub struct AuthOptions { pub allow_device_credential: bool, /// Label for the Cancel button. This feature is available on both Android and iOS. pub cancel_title: Option, + /// The plain data that must be encrypted after successfull biometric authentication + pub data_to_encrypt: Option, + /// The encrypted data that must be decrypted after successfull biometric authentication + pub data_to_decrypt: Option, /// Specifies the text displayed on the fallback button if biometric authentication fails. This feature is available iOS only. pub fallback_title: Option, /// Title indicating the purpose of biometric verification. This feature is available Android only. @@ -37,3 +41,8 @@ pub struct Status { pub error: Option, pub error_code: Option, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CipherResult { + pub data: String +} \ No newline at end of file From 19c1511231279ad0a9d69ce3f857226aff86b0bd Mon Sep 17 00:00:00 2001 From: Charles Schaefer Date: Sun, 23 Feb 2025 18:10:07 -0300 Subject: [PATCH 2/6] Improving the way we encrypt data by allowing only if the biometric was successful. --- plugins/biometric/android/src/main/java/BiometricActivity.kt | 4 ++-- plugins/biometric/api-iife.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/biometric/android/src/main/java/BiometricActivity.kt b/plugins/biometric/android/src/main/java/BiometricActivity.kt index 4a4a68ce..ecaba82d 100644 --- a/plugins/biometric/android/src/main/java/BiometricActivity.kt +++ b/plugins/biometric/android/src/main/java/BiometricActivity.kt @@ -89,8 +89,8 @@ class BiometricActivity : AppCompatActivity() { prefix + BiometricPlugin.ENCRYPT_DECRYPT_OPERATION, cipherOperation ) - - if (cipherOperation) { + + if (resultType == BiometryResultType.SUCCESS && cipherOperation) { val cryptoObject = requireNotNull(authenticationResult?.cryptoObject) val cipher = requireNotNull(cryptoObject.cipher) var dataToProcess = if (cipherType == CipherType.ENCRYPT) { diff --git a/plugins/biometric/api-iife.js b/plugins/biometric/api-iife.js index 3da2296b..c9b85266 100644 --- a/plugins/biometric/api-iife.js +++ b/plugins/biometric/api-iife.js @@ -1 +1 @@ -if("__TAURI__"in window){var __TAURI_PLUGIN_BIOMETRIC__=function(e){"use strict";async function i(e,i={},t){return window.__TAURI_INTERNALS__.invoke(e,i,t)}var t;return"function"==typeof SuppressedError&&SuppressedError,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("plugin:biometric|authenticate",{reason:e,...t})},e.checkStatus=async function(){return await i("plugin:biometric|status")},e}({});Object.defineProperty(window.__TAURI__,"biometric",{value:__TAURI_PLUGIN_BIOMETRIC__})} +if("__TAURI__"in window){var __TAURI_PLUGIN_BIOMETRIC__=function(i){"use strict";async function e(i,e={},t){return window.__TAURI_INTERNALS__.invoke(i,e,t)}var t;return"function"==typeof SuppressedError&&SuppressedError,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("plugin:biometric|authenticate",{reason:i,...t})},i.biometricCipher=async function(i,t){return await e("plugin:biometric|biometric_cipher",{reason:i,...t})},i.checkStatus=async function(){return await e("plugin:biometric|status")},i}({});Object.defineProperty(window.__TAURI__,"biometric",{value:__TAURI_PLUGIN_BIOMETRIC__})} From 83db0d9fee250e54020ecef831a0e096685424ac Mon Sep 17 00:00:00 2001 From: Charles Schaefer Date: Sun, 23 Feb 2025 18:39:00 -0300 Subject: [PATCH 3/6] Adding a warning on the JS side --- plugins/biometric/guest-js/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/biometric/guest-js/index.ts b/plugins/biometric/guest-js/index.ts index a88ea490..ec9cea10 100644 --- a/plugins/biometric/guest-js/index.ts +++ b/plugins/biometric/guest-js/index.ts @@ -93,6 +93,9 @@ export async function 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"; From 061de33b2764a016a688b5cec8723fa569152c64 Mon Sep 17 00:00:00 2001 From: Charles Schaefer Date: Mon, 24 Feb 2025 19:13:23 -0300 Subject: [PATCH 4/6] Adding doc changes in readme.md --- plugins/biometric/README.md | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/plugins/biometric/README.md b/plugins/biometric/README.md index c7844f7b..684b143d 100644 --- a/plugins/biometric/README.md +++ b/plugins/biometric/README.md @@ -1,6 +1,6 @@ ![biometric](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/biometric/banner.png) -Prompt the user for biometric authentication on Android and iOS. +Prompt the user for biometric authentication on Android and iOS. Also allows to use assymetric key protected | Platform | Supported | | -------- | --------- | @@ -8,7 +8,7 @@ Prompt the user for biometric authentication on Android and iOS. | Windows | x | | macOS | x | | Android | ✓ | -| iOS | ✓ | +| iOS | ✓ (except biometric cryptography ) | ## Install @@ -70,8 +70,27 @@ 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.isAvailble) { + 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); +} + + ``` ## Contributing From ee369b0a9bcc72a7b3ebdb0161ab971d9dfd05eb Mon Sep 17 00:00:00 2001 From: Charles Schaefer Date: Tue, 25 Feb 2025 04:27:57 -0300 Subject: [PATCH 5/6] Adding doc changes in readme.md --- plugins/biometric/README.md | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/plugins/biometric/README.md b/plugins/biometric/README.md index c7844f7b..684b143d 100644 --- a/plugins/biometric/README.md +++ b/plugins/biometric/README.md @@ -1,6 +1,6 @@ ![biometric](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/biometric/banner.png) -Prompt the user for biometric authentication on Android and iOS. +Prompt the user for biometric authentication on Android and iOS. Also allows to use assymetric key protected | Platform | Supported | | -------- | --------- | @@ -8,7 +8,7 @@ Prompt the user for biometric authentication on Android and iOS. | Windows | x | | macOS | x | | Android | ✓ | -| iOS | ✓ | +| iOS | ✓ (except biometric cryptography ) | ## Install @@ -70,8 +70,27 @@ 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.isAvailble) { + 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); +} + + ``` ## Contributing From a3809050fe7687c02956362c61d7f257ed64132e Mon Sep 17 00:00:00 2001 From: Charles Schaefer Date: Tue, 25 Feb 2025 04:41:17 -0300 Subject: [PATCH 6/6] Update README.md - it was updated incomplete --- plugins/biometric/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/biometric/README.md b/plugins/biometric/README.md index 684b143d..af66a103 100644 --- a/plugins/biometric/README.md +++ b/plugins/biometric/README.md @@ -1,6 +1,6 @@ ![biometric](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/biometric/banner.png) -Prompt the user for biometric authentication on Android and iOS. Also allows to use assymetric key protected +Prompt the user for biometric authentication on Android and iOS. Also allows to use assymetric cryptography with the keys protected by biometric authentication. | Platform | Supported | | -------- | --------- |