tauri-plugin-keystore 2.1.0-alpha.2

Interact with the device-native key storage (Android Keystore, iOS Keychain).
package app.tauri.keystore

import android.app.Activity
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties
import android.util.Base64
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
import org.json.JSONObject
import java.nio.charset.Charset
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec

private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val SHARED_PREFERENCES_NAME = "secure_storage"
private const val GCM_TAG_LENGTH_BITS = 128

private const val DEFAULT_PROMPT_TITLE = "Authentication required"
private const val DEFAULT_CANCEL_LABEL = "Cancel"

@InvokeArg
class Prompt {
    var title: String? = null
    var subtitle: String? = null
    var cancelLabel: String? = null
    var reason: String? = null
}

@InvokeArg
class StoreRequest {
    lateinit var key: String
    lateinit var value: String
    var prompt: Prompt? = null
}

@InvokeArg
class RetrieveRequest {
    lateinit var key: String
    var prompt: Prompt? = null
}

@InvokeArg
class RemoveRequest {
    lateinit var key: String
}

@TauriPlugin
class KeystorePlugin(private val activity: Activity) : Plugin(activity) {

    @Command
    fun store(invoke: Invoke) {
        val args = invoke.parseArgs(StoreRequest::class.java)

        activity.runOnUiThread {
            val cipher = try {
                generateKeyIfAbsent(args.key)
                encryptionCipher(args.key)
            } catch (e: KeyPermanentlyInvalidatedException) {
                // The enrolled biometrics changed, so the existing key can no
                // longer be used. Nothing was encrypted with it that we cannot
                // discard, so replace it and carry on.
                try {
                    deleteKey(args.key)
                    clearCipherData(args.key)
                    generateKeyIfAbsent(args.key)
                    encryptionCipher(args.key)
                } catch (e: Exception) {
                    invoke.reject("Could not prepare key: ${e.message}")
                    return@runOnUiThread
                }
            } catch (e: Exception) {
                invoke.reject("Could not prepare key: ${e.message}")
                return@runOnUiThread
            }

            authenticate(invoke, args.prompt, cipher) { authenticated ->
                try {
                    val ciphertext =
                        authenticated.doFinal(args.value.toByteArray(Charset.forName("UTF-8")))
                    writeCipherData(args.key, authenticated.iv, ciphertext)
                    invoke.resolve()
                } catch (e: Exception) {
                    invoke.reject("Encryption failed: ${e.message}")
                }
            }
        }
    }

    @Command
    fun retrieve(invoke: Invoke) {
        val args = invoke.parseArgs(RetrieveRequest::class.java)

        val cipherData = readCipherData(args.key)
        if (cipherData == null) {
            // Nothing stored under this key is not an error. `JSONObject.NULL`
            // emits an explicit JSON null; a plain `null` would drop the key.
            invoke.resolve(JSObject().apply { put("value", JSONObject.NULL) })
            return
        }
        val (iv, ciphertext) = cipherData

        activity.runOnUiThread {
            val cipher = try {
                decryptionCipher(args.key, iv)
            } catch (e: KeyPermanentlyInvalidatedException) {
                invoke.reject(
                    "The key for '${args.key}' was invalidated by a change to the enrolled biometrics"
                )
                return@runOnUiThread
            } catch (e: Exception) {
                invoke.reject("Could not prepare key: ${e.message}")
                return@runOnUiThread
            }

            authenticate(invoke, args.prompt, cipher) { authenticated ->
                try {
                    val cleartext = String(authenticated.doFinal(ciphertext), Charset.forName("UTF-8"))
                    invoke.resolve(JSObject().apply { put("value", cleartext) })
                } catch (e: Exception) {
                    invoke.reject("Decryption failed: ${e.message}")
                }
            }
        }
    }

    @Command
    fun remove(invoke: Invoke) {
        val args = invoke.parseArgs(RemoveRequest::class.java)
        try {
            deleteKey(args.key)
            clearCipherData(args.key)
            invoke.resolve()
        } catch (e: Exception) {
            invoke.reject("Could not remove '${args.key}': ${e.localizedMessage}")
        }
    }

    /**
     * Shows the biometric prompt and hands [onAuthenticated] the unlocked cipher.
     *
     * Must be called on the UI thread.
     */
    private fun authenticate(
        invoke: Invoke,
        prompt: Prompt?,
        cipher: Cipher,
        onAuthenticated: (Cipher) -> Unit
    ) {
        val callback = object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                super.onAuthenticationSucceeded(result)
                val authenticated = result.cryptoObject?.cipher
                if (authenticated == null) {
                    invoke.reject("No cipher available after authentication")
                    return
                }
                onAuthenticated(authenticated)
            }

            override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                super.onAuthenticationError(errorCode, errString)
                invoke.reject("Authentication error: $errString", errorCode.toString())
            }

            // Deliberately not overridden: a single unrecognised attempt leaves the
            // prompt open for the user to retry, so it must not settle the invoke.
            // `onAuthenticationError` fires once the attempt is finally abandoned.
        }

        val info = BiometricPrompt.PromptInfo.Builder()
            .setTitle(prompt?.title ?: DEFAULT_PROMPT_TITLE)
            .setNegativeButtonText(prompt?.cancelLabel ?: DEFAULT_CANCEL_LABEL)
            .apply { prompt?.subtitle?.let { setSubtitle(it) } }
            .build()

        BiometricPrompt(
            activity as FragmentActivity,
            ContextCompat.getMainExecutor(activity),
            callback
        ).authenticate(info, BiometricPrompt.CryptoObject(cipher))
    }

    private fun keyStore(): KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }

    private fun generateKeyIfAbsent(key: String) {
        if (keyStore().containsAlias(key)) return

        val spec = KeyGenParameterSpec.Builder(
            key,
            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
        )
            .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
            .setUserAuthenticationRequired(true)
            .apply {
                // Require authentication for every single use.
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
                    setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
                } else {
                    @Suppress("DEPRECATION")
                    setUserAuthenticationValidityDurationSeconds(-1)
                }
            }
            .build()

        KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE).run {
            init(spec)
            generateKey()
        }
    }

    private fun deleteKey(key: String) = keyStore().deleteEntry(key)

    private fun secretKey(key: String): SecretKey = keyStore().getKey(key, null) as SecretKey

    private fun encryptionCipher(key: String): Cipher =
        Cipher.getInstance("AES/GCM/NoPadding").apply { init(Cipher.ENCRYPT_MODE, secretKey(key)) }

    private fun decryptionCipher(key: String, iv: ByteArray): Cipher =
        Cipher.getInstance("AES/GCM/NoPadding").apply {
            init(Cipher.DECRYPT_MODE, secretKey(key), GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv))
        }

    private fun preferences(): SharedPreferences =
        activity.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE)

    // The ciphertext lives in SharedPreferences alongside the key that encrypted
    // it, so both entries are namespaced by the caller's key.
    private fun ivPref(key: String) = "$key.iv"

    private fun ciphertextPref(key: String) = "$key.ciphertext"

    private fun writeCipherData(key: String, iv: ByteArray, ciphertext: ByteArray) {
        preferences().edit()
            .putString(ivPref(key), Base64.encodeToString(iv, Base64.DEFAULT))
            .putString(ciphertextPref(key), Base64.encodeToString(ciphertext, Base64.DEFAULT))
            .apply()
    }

    private fun readCipherData(key: String): Pair<ByteArray, ByteArray>? {
        val prefs = preferences()
        val iv = prefs.getString(ivPref(key), null) ?: return null
        val ciphertext = prefs.getString(ciphertextPref(key), null) ?: return null
        return Pair(Base64.decode(iv, Base64.DEFAULT), Base64.decode(ciphertext, Base64.DEFAULT))
    }

    private fun clearCipherData(key: String) {
        preferences().edit().remove(ivPref(key)).remove(ciphertextPref(key)).apply()
    }
}