diff --git a/.changes/biometric-android-encrypt-decrypt b/.changes/biometric-android-encrypt-decrypt new file mode 100644 index 0000000000..7f26627bb3 --- /dev/null +++ b/.changes/biometric-android-encrypt-decrypt @@ -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. \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 591a7349ab..720dfdab46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6520,7 +6520,7 @@ dependencies = [ [[package]] name = "tauri-plugin-biometric" -version = "2.3.0" +version = "2.4.0" dependencies = [ "log", "serde", diff --git a/plugins/biometric/Cargo.toml b/plugins/biometric/Cargo.toml index 1da605fb83..a024325d6f 100644 --- a/plugins/biometric/Cargo.toml +++ b/plugins/biometric/Cargo.toml @@ -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 } diff --git a/plugins/biometric/README.md b/plugins/biometric/README.md index e2ad7efde9..bc00f2594d 100644 --- a/plugins/biometric/README.md +++ b/plugins/biometric/README.md @@ -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. diff --git a/plugins/biometric/android/src/main/java/BiometricActivity.kt b/plugins/biometric/android/src/main/java/BiometricActivity.kt index 011de4d5fc..1fbc5b8a8b 100644 --- a/plugins/biometric/android/src/main/java/BiometricActivity.kt +++ b/plugins/biometric/android/src/main/java/BiometricActivity.kt @@ -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) { @@ -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( @@ -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 } @@ -87,6 +99,7 @@ class BiometricActivity : AppCompatActivity() { ) { super.onAuthenticationError(errorCode, errorMessage) finishActivity( + null, BiometryResultType.ERROR, errorCode, errorMessage as String @@ -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? = "" @@ -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() } diff --git a/plugins/biometric/android/src/main/java/BiometricPlugin.kt b/plugins/biometric/android/src/main/java/BiometricPlugin.kt index b3436fd48d..8eb2b16153 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 AuthMode { + PROMPT, ENCRYPT, DECRYPT +} + private const val MAX_ATTEMPTS = "maxAttemps" private const val BIOMETRIC_FAILURE = "authenticationFailed" private const val INVALID_CONTEXT_ERROR = "invalidContext" @@ -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 = 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" @@ -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) @@ -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 ) @@ -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) diff --git a/plugins/biometric/android/src/main/java/CryptographyManager.kt b/plugins/biometric/android/src/main/java/CryptographyManager.kt new file mode 100644 index 0000000000..fbbf4cd5ec --- /dev/null +++ b/plugins/biometric/android/src/main/java/CryptographyManager.kt @@ -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() + } +} \ No newline at end of file diff --git a/plugins/biometric/api-iife.js b/plugins/biometric/api-iife.js index 3da2296ba4..4c9daa62f4 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(e){"use strict";async function t(e,t={},i){return window.__TAURI_INTERNALS__.invoke(e,t,i)}var i,n;return"function"==typeof SuppressedError&&SuppressedError,e.AuthMode=void 0,(i=e.AuthMode||(e.AuthMode={}))[i.PROMPT=0]="PROMPT",i[i.ENCRYPT=1]="ENCRYPT",i[i.DECRYPT=2]="DECRYPT",e.BiometryType=void 0,(n=e.BiometryType||(e.BiometryType={}))[n.None=0]="None",n[n.TouchID=1]="TouchID",n[n.FaceID=2]="FaceID",n[n.Iris=3]="Iris",e.authenticate=async function(e,i){return await t("plugin:biometric|authenticate",{reason:e,...i})},e.checkStatus=async function(){return await t("plugin:biometric|status")},e}({});Object.defineProperty(window.__TAURI__,"biometric",{value:__TAURI_PLUGIN_BIOMETRIC__})} diff --git a/plugins/biometric/guest-js/index.ts b/plugins/biometric/guest-js/index.ts index 5c3eb8df22..c09fa4fde3 100644 --- a/plugins/biometric/guest-js/index.ts +++ b/plugins/biometric/guest-js/index.ts @@ -4,6 +4,12 @@ import { invoke } from '@tauri-apps/api/core' +export enum AuthMode { + PROMPT = 0, + ENCRYPT = 1, + DECRYPT = 2, +} + export enum BiometryType { None = 0, // Apple TouchID or Android fingerprint @@ -32,20 +38,45 @@ export interface Status { | 'biometryNotEnrolled' } -export interface AuthOptions { - allowDeviceCredential?: boolean - cancelTitle?: string +export interface DecryptedCipherData { + data: string, +} + +export interface EncryptedCipherData { + data: string, + initializationVector: string, +} + +export type CipherData = DecryptedCipherData | EncryptedCipherData; - // iOS options - fallbackTitle?: string +interface BaseAuthOptions { + allowDeviceCredential?: boolean; + cancelTitle?: string; + fallbackTitle?: string; + title?: string; + subtitle?: string; + confirmationRequired?: boolean; + maxAttemps?: number; +} + +interface PromptAuthOptions extends BaseAuthOptions { + mode: AuthMode.PROMPT; +} - // android options - title?: string - subtitle?: string - confirmationRequired?: boolean - maxAttemps?: number +interface EncryptAuthOptions extends BaseAuthOptions { + mode: AuthMode.ENCRYPT; + cipherKey: string; + cipherData: DecryptedCipherData; } +interface DecryptAuthOptions extends BaseAuthOptions { + mode: AuthMode.DECRYPT; + cipherKey: string; + cipherData: EncryptedCipherData; +} + +export type AuthOptions = BaseAuthOptions | PromptAuthOptions | EncryptAuthOptions | DecryptAuthOptions; + /** * Checks if the biometric authentication is available. * @returns a promise resolving to an object containing all the information about the status of the biometry. @@ -64,13 +95,13 @@ export async function checkStatus(): Promise { * ``` * @param reason * @param options - * @returns + * @returns a promise resolving to an object containing the encrypted or decrypted data if any */ export async function authenticate( reason: string, options?: AuthOptions -): Promise { - await invoke('plugin:biometric|authenticate', { +): Promise { + return await invoke('plugin:biometric|authenticate', { reason, ...options }) diff --git a/plugins/biometric/package.json b/plugins/biometric/package.json index e6c4d98816..5a7b923345 100644 --- a/plugins/biometric/package.json +++ b/plugins/biometric/package.json @@ -1,6 +1,6 @@ { "name": "@tauri-apps/plugin-biometric", - "version": "2.3.0", + "version": "2.4.0", "license": "MIT OR Apache-2.0", "authors": [ "Tauri Programme within The Commons Conservancy" diff --git a/plugins/biometric/src/lib.rs b/plugins/biometric/src/lib.rs index f79a104d34..eaa097112c 100644 --- a/plugins/biometric/src/lib.rs +++ b/plugins/biometric/src/lib.rs @@ -38,7 +38,7 @@ impl Biometric { self.0.run_mobile_plugin("status", ()).map_err(Into::into) } - pub fn authenticate(&self, reason: String, options: AuthOptions) -> crate::Result<()> { + pub fn authenticate(&self, reason: String, options: AuthOptions) -> crate::Result { self.0 .run_mobile_plugin("authenticate", AuthenticatePayload { reason, options }) .map_err(Into::into) diff --git a/plugins/biometric/src/models.rs b/plugins/biometric/src/models.rs index 49c8430042..5ce3c66600 100644 --- a/plugins/biometric/src/models.rs +++ b/plugins/biometric/src/models.rs @@ -19,6 +19,18 @@ pub struct AuthOptions { pub subtitle: Option, /// Specifies whether additional user confirmation is required, such as pressing a button after successful biometric authentication. This feature is available Android only. pub confirmation_required: Option, + pub max_attemps: Option, + pub mode: Option, + pub cipher_key: Option, + pub cipher_data: Option, +} + +#[derive(Debug, Clone, Serialize, serde_repr::Deserialize_repr)] +#[repr(u8)] +pub enum AuthMode { + PROMPT = 0, + ENCRYPT = 1, + DECRYPT = 2, } #[derive(Debug, Clone, serde_repr::Deserialize_repr)] @@ -37,3 +49,10 @@ pub struct Status { pub error: Option, pub error_code: Option, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CipherData { + pub data: Option, + pub initialization_vector: Option, +} \ No newline at end of file