–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.
pull/2454/head
Charles Schaefer 5 months ago
parent e0cff9bcf4
commit 779c7cd8f6
No known key found for this signature in database

@ -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<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 allowDeviceCredential = false
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] = "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,10 +167,9 @@ 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(
@ -168,8 +177,6 @@ class BiometricPlugin(private val activity: Activity): Plugin(activity) {
BiometricActivity::class.java
)
val args = invoke.parseArgs(AuthOptions::class.java)
// Pass the options to the activity
intent.putExtra(
TITLE,
@ -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)

@ -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)

@ -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
});
}

@ -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"]

@ -50,6 +50,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>

@ -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",

@ -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<R: Runtime>(PluginHandle<R>);
@ -43,6 +44,12 @@ impl<R: Runtime> Biometric<R> {
.run_mobile_plugin("authenticate", AuthenticatePayload { reason, options })
.map_err(Into::into)
}
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)
}
}
/// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the biometric APIs.

@ -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<String>,
/// The plain data that must be encrypted after successfull biometric authentication
pub data_to_encrypt: Option<String>,
/// The encrypted data that must be decrypted after successfull biometric authentication
pub data_to_decrypt: Option<String>,
/// Specifies the text displayed on the fallback button if biometric authentication fails. This feature is available iOS only.
pub fallback_title: Option<String>,
/// Title indicating the purpose of biometric verification. This feature is available Android only.
@ -37,3 +41,8 @@ pub struct Status {
pub error: Option<String>,
pub error_code: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CipherResult {
pub data: String
}
Loading…
Cancel
Save