From b46da6a9d4f5d84815a61202502538faf9fb9cdf Mon Sep 17 00:00:00 2001 From: 0x330a <92654767+0x330a@users.noreply.github.com> Date: Wed, 16 Apr 2025 15:05:53 +0700 Subject: [PATCH 01/33] feat: add shared secret hkdf from p256 pubkey using secure element --- Cargo.toml | 3 + android/build.gradle.kts | 3 +- android/settings.gradle | 2 +- android/src/main/java/KeystorePlugin.kt | 133 +++++++++++++++--- build.rs | 2 +- .../autogenerated/commands/shared_secret.toml | 13 ++ permissions/autogenerated/reference.md | 28 ++++ permissions/schemas/schema.json | 39 +++-- src/commands.rs | 36 +++++ src/error.rs | 2 + src/lib.rs | 5 +- src/mobile.rs | 7 + src/models.rs | 19 +++ 13 files changed, 260 insertions(+), 32 deletions(-) create mode 100644 permissions/autogenerated/commands/shared_secret.toml diff --git a/Cargo.toml b/Cargo.toml index 7a8314e..787b8d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,9 @@ license = "Apache-2.0" tauri = { version = "2" } serde = "1.0" thiserror = "2" +hkdf = { version = "0.12" } +sha2 = "0.10.8" +hex = "0.4.3" [build-dependencies] tauri-plugin = { version = "2.0.3", features = ["build"] } diff --git a/android/build.gradle.kts b/android/build.gradle.kts index b3b3018..16697cb 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -8,7 +8,7 @@ android { compileSdk = 34 defaultConfig { - minSdk = 28 // currently supported: 31+ (Android 12: 'Snow Cone', Oct 2021) + minSdk = 31 // currently supported: 31+ (Android 12: 'Snow Cone', Oct 2021) testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") @@ -37,6 +37,7 @@ dependencies { implementation("androidx.core:core-ktx:1.9.0") implementation("androidx.appcompat:appcompat:1.6.0") implementation("com.google.android.material:material:1.7.0") + implementation("com.github.komputing.khex:core:1.1.3") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") diff --git a/android/settings.gradle b/android/settings.gradle index d7782a4..802d01c 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -23,7 +23,7 @@ dependencyResolutionManagement { repositories { mavenCentral() google() - + maven { url 'https://jitpack.io' } } } diff --git a/android/src/main/java/KeystorePlugin.kt b/android/src/main/java/KeystorePlugin.kt index e5d4049..a86c61f 100644 --- a/android/src/main/java/KeystorePlugin.kt +++ b/android/src/main/java/KeystorePlugin.kt @@ -5,7 +5,6 @@ import android.content.Context import android.content.SharedPreferences //import android.hardware.biometrics.BiometricPrompt import androidx.biometric.BiometricPrompt -import java.security.KeyStore import app.tauri.annotation.Command import app.tauri.annotation.InvokeArg import app.tauri.annotation.TauriPlugin @@ -14,17 +13,25 @@ import android.util.Base64 import app.tauri.plugin.JSObject import app.tauri.plugin.Plugin import app.tauri.plugin.Invoke -import java.util.Enumeration import javax.crypto.KeyGenerator import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import androidx.core.content.ContextCompat -import java.nio.charset.Charset +import org.komputing.khex.decode +import org.komputing.khex.encode +import java.math.BigInteger +import java.security.* +import java.security.spec.ECGenParameterSpec +import java.security.spec.ECParameterSpec +import java.security.spec.ECPoint +import java.security.spec.ECPublicKeySpec import javax.crypto.Cipher +import javax.crypto.KeyAgreement import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec -private const val KEY_ALIAS = "unime_dev" +private const val KEY_ALIAS = "key_alias" +private const val KEY_AGREEMENT_ALIAS = "key_agreement_alias" private const val ANDROID_KEYSTORE = "AndroidKeyStore" private const val SHARED_PREFERENCES_NAME = "secure_storage" @@ -41,6 +48,15 @@ class RetrieveRequest { lateinit var user: String } +@InvokeArg +class SharedSecretRequest { + lateinit var withP256PubKey: String +} + +data class SharedSecretResponse( + val sharedSecret: String +) + @TauriPlugin class KeystorePlugin(private val activity: Activity) : Plugin(activity) { private val implementation = Example() @@ -72,9 +88,8 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { // Encrypt the value. val ciphertext = - authCipher.doFinal(storeRequest.value.toByteArray(Charset.forName("UTF-8"))) + authCipher.doFinal(storeRequest.value.toByteArray()) val iv = authCipher.iv // Capture the initialization vector. - // Store the ciphertext and IV. storeCiphertext(iv, ciphertext) Logger.info("Secret stored securely") @@ -160,16 +175,101 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { } } + fun getPublicKeyFromHex(hexPublicKey: String): PublicKey { + // Handle uncompressed format (starting with 04) + if (hexPublicKey.startsWith("04")) { + val hexString = hexPublicKey.substring(2) // Remove "04" prefix + val coordinateLength = hexString.length / 2 + + // Extract x and y coordinates (each should be 64 chars for secp256r1) + val xHex = hexString.substring(0, coordinateLength) + val yHex = hexString.substring(coordinateLength) + + val x = BigInteger(xHex, 16) + val y = BigInteger(yHex, 16) + + // Create EC point + val ecPoint = ECPoint(x, y) + + // Get secp256r1 parameters + val params = AlgorithmParameters.getInstance("EC") + params.init(ECGenParameterSpec("secp256r1")) + val ecParameterSpec = params.getParameterSpec(ECParameterSpec::class.java) + + // Create public key spec and generate the public key + val pubKeySpec = ECPublicKeySpec(ecPoint, ecParameterSpec) + val keyFactory = KeyFactory.getInstance("EC") + return keyFactory.generatePublic(pubKeySpec) + } + // Handle compressed format (starting with 02 or 03) + else if (hexPublicKey.startsWith("02") || hexPublicKey.startsWith("03")) { + // This requires point decompression which is more complex + // Consider using Bouncy Castle for this + throw IllegalArgumentException("Compressed keys not supported in this simple implementation") + } + else { + throw IllegalArgumentException("Invalid public key format") + } + } + + private fun ensureP256Key(): Key? { + val keystore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + if (!keystore.containsAlias(KEY_AGREEMENT_ALIAS)) { + val keyGenerator = + KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, ANDROID_KEYSTORE) + + val keyGenParameterSpec = KeyGenParameterSpec.Builder( + KEY_AGREEMENT_ALIAS, + KeyProperties.PURPOSE_AGREE_KEY or KeyProperties.PURPOSE_SIGN + ) + .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) + .build() + + keyGenerator.initialize(keyGenParameterSpec) + keyGenerator.generateKeyPair() + } + + return keystore.getKey(KEY_AGREEMENT_ALIAS, null) + } + + + @Command + fun shared_secret(invoke: Invoke) { + val params = invoke.parseArgs(SharedSecretRequest::class.java) + + // ensure we have generated the key + val key = ensureP256Key() + val agreement = getAgreement() + + Logger.debug("got param: ${params.withP256PubKey}") + + + + Logger.debug("got key: $key") + Logger.debug("got agreement: $agreement") + Logger.debug("trying to use:") + + agreement.init(key) + agreement.doPhase(getPublicKeyFromHex(params.withP256PubKey), true) + val secret = agreement.generateSecret() + invoke.resolveObject(SharedSecretResponse(encode(secret, prefix = ""))) + } + + private fun getAgreement(): KeyAgreement { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + + val privateKey = keyStore.getKey(KEY_AGREEMENT_ALIAS, null) + val keyAgreement = KeyAgreement.getInstance("ECDH") + keyAgreement.init(privateKey) + + return keyAgreement + } + // Prepares and returns a Cipher instance for encryption using the key from the Keystore. private fun getEncryptionCipher(): Cipher { val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } - // ######## TODO: remove - val aliases: Enumeration = keyStore.aliases() - Logger.warn("########## aliases:", aliases.toList().joinToString()) - // ############### - - val secretKey = keyStore.getKey(KEY_ALIAS, null) as SecretKey + val secretKey = keyStore.getKey(KEY_ALIAS, null) val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init(Cipher.ENCRYPT_MODE, secretKey) return cipher @@ -193,7 +293,7 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { val cipherData = readCipherData() if (cipherData == null) { - invoke.reject("No cipher data found in SharedPreferences", "001") + invoke.resolve(JSObject("{value: null}")) return } @@ -216,13 +316,13 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { val authCipher = result.cryptoObject?.cipher ?: throw IllegalStateException("Cipher not available after authentication") val decryptedBytes = authCipher.doFinal(ciphertext) - val cleartext = String(decryptedBytes, Charset.forName("UTF-8")) + val cleartext = String(decryptedBytes) val ret = JSObject() ret.put("value", cleartext) invoke.resolve(ret) } catch (e: Exception) { - invoke.reject("Decryption failed: ${e.message}") + invoke.reject("Decryption failed: $e") } } @@ -266,8 +366,7 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } val secretKey = keyStore.getKey(KEY_ALIAS, null) as SecretKey val cipher = Cipher.getInstance("AES/GCM/NoPadding") - val spec = GCMParameterSpec(128, iv) - cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) + cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, iv)) return cipher } diff --git a/build.rs b/build.rs index 01645f9..7c28f6c 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,4 @@ -const COMMANDS: &[&str] = &["remove", "retrieve", "store"]; +const COMMANDS: &[&str] = &["remove", "retrieve", "store", "shared_secret"]; fn main() { tauri_plugin::Builder::new(COMMANDS) diff --git a/permissions/autogenerated/commands/shared_secret.toml b/permissions/autogenerated/commands/shared_secret.toml new file mode 100644 index 0000000..250df5f --- /dev/null +++ b/permissions/autogenerated/commands/shared_secret.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-shared-secret" +description = "Enables the shared_secret command without any pre-configured scope." +commands.allow = ["shared_secret"] + +[[permission]] +identifier = "deny-shared-secret" +description = "Denies the shared_secret command without any pre-configured scope." +commands.deny = ["shared_secret"] diff --git a/permissions/autogenerated/reference.md b/permissions/autogenerated/reference.md index d696b62..4285034 100644 --- a/permissions/autogenerated/reference.md +++ b/permissions/autogenerated/reference.md @@ -2,6 +2,8 @@ Default permissions for the plugin +#### This default permission set includes the following: + - `allow-remove` - `allow-retrieve` - `allow-store` @@ -70,6 +72,32 @@ Denies the retrieve command without any pre-configured scope. +`keystore:allow-shared-secret` + + + + +Enables the shared_secret command without any pre-configured scope. + + + + + + + +`keystore:deny-shared-secret` + + + + +Denies the shared_secret command without any pre-configured scope. + + + + + + + `keystore:allow-store` diff --git a/permissions/schemas/schema.json b/permissions/schemas/schema.json index e89561d..c39a60d 100644 --- a/permissions/schemas/schema.json +++ b/permissions/schemas/schema.json @@ -49,7 +49,7 @@ "minimum": 1.0 }, "description": { - "description": "Human-readable description of what the permission does. Tauri convention is to use

headings in markdown content for Tauri documentation generation purposes.", + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", "type": [ "string", "null" @@ -111,7 +111,7 @@ "type": "string" }, "description": { - "description": "Human-readable description of what the permission does. Tauri internal convention is to use

headings in markdown content for Tauri documentation generation purposes.", + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", "type": [ "string", "null" @@ -297,37 +297,56 @@ { "description": "Enables the remove command without any pre-configured scope.", "type": "string", - "const": "allow-remove" + "const": "allow-remove", + "markdownDescription": "Enables the remove command without any pre-configured scope." }, { "description": "Denies the remove command without any pre-configured scope.", "type": "string", - "const": "deny-remove" + "const": "deny-remove", + "markdownDescription": "Denies the remove command without any pre-configured scope." }, { "description": "Enables the retrieve command without any pre-configured scope.", "type": "string", - "const": "allow-retrieve" + "const": "allow-retrieve", + "markdownDescription": "Enables the retrieve command without any pre-configured scope." }, { "description": "Denies the retrieve command without any pre-configured scope.", "type": "string", - "const": "deny-retrieve" + "const": "deny-retrieve", + "markdownDescription": "Denies the retrieve command without any pre-configured scope." + }, + { + "description": "Enables the shared_secret command without any pre-configured scope.", + "type": "string", + "const": "allow-shared-secret", + "markdownDescription": "Enables the shared_secret command without any pre-configured scope." + }, + { + "description": "Denies the shared_secret command without any pre-configured scope.", + "type": "string", + "const": "deny-shared-secret", + "markdownDescription": "Denies the shared_secret command without any pre-configured scope." }, { "description": "Enables the store command without any pre-configured scope.", "type": "string", - "const": "allow-store" + "const": "allow-store", + "markdownDescription": "Enables the store command without any pre-configured scope." }, { "description": "Denies the store command without any pre-configured scope.", "type": "string", - "const": "deny-store" + "const": "deny-store", + "markdownDescription": "Denies the store command without any pre-configured scope." }, { - "description": "Default permissions for the plugin", + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-remove`\n- `allow-retrieve`\n- `allow-store`", "type": "string", - "const": "default" + "const": "default", + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-remove`\n- `allow-retrieve`\n- `allow-store`" } ] } diff --git a/src/commands.rs b/src/commands.rs index b38e52b..c31c305 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,3 +1,6 @@ +use hex::ToHex; +use hkdf::Hkdf; +use sha2::{Digest, Sha512}; use tauri::{command, AppHandle, Runtime}; use crate::models::*; @@ -26,3 +29,36 @@ pub(crate) async fn remove( ) -> crate::Result<()> { app.keystore().remove(payload) } + +#[command] +pub(crate) async fn shared_secret ( + app: AppHandle, + payload: SharedSecretRequest +) -> crate::Result { + + let extra_info = payload.extra_info.clone(); + + let shared_secret_result = app.keystore().shared_secret(payload)?; + + let salt_hasher = Sha512::new(); + + let ikm: Vec = hex::decode(&shared_secret_result.shared_secret).map_err(|e| { + crate::Error::Io(std::io::Error::new(std::io::ErrorKind::Other, e)) + })?; + + let mut salt = salt_hasher + .chain_update(b"tauri-plugin-keystore"); + if let Some(extra) = extra_info { + salt = salt.chain_update(extra.as_bytes()); + } + + let hk = Hkdf::::new(Some(&salt.finalize()), &ikm); + let mut okm = [0u8;32]; + hk.expand(&[], &mut okm).map_err(|e| { + crate::Error::Generic("invalid length".to_string()) + })?; + + Ok(ChaChaSharedSecret { + chacha_20_key: okm.encode_hex(), + }) +} \ No newline at end of file diff --git a/src/error.rs b/src/error.rs index 895220d..a3de979 100644 --- a/src/error.rs +++ b/src/error.rs @@ -4,6 +4,8 @@ pub type Result = std::result::Result; #[derive(Debug, thiserror::Error)] pub enum Error { + #[error("Generic Error: {0}")] + Generic(String), #[error(transparent)] Io(#[from] std::io::Error), #[cfg(mobile)] diff --git a/src/lib.rs b/src/lib.rs index ae037ea..2fdb603 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,7 +22,7 @@ pub trait KeystoreExt { fn keystore(&self) -> &Keystore; } -impl> crate::KeystoreExt for T { +impl> KeystoreExt for T { fn keystore(&self) -> &Keystore { self.state::>().inner() } @@ -34,7 +34,8 @@ pub fn init() -> TauriPlugin { .invoke_handler(tauri::generate_handler![ commands::remove, commands::retrieve, - commands::store + commands::store, + commands::shared_secret ]) .setup(|app, api| { let keystore = mobile::init(app, api)?; diff --git a/src/mobile.rs b/src/mobile.rs index 0e8c3bd..43a3c13 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -42,4 +42,11 @@ impl Keystore { .run_mobile_plugin("remove", payload) .map_err(Into::into) } + + pub fn shared_secret(&self, payload: SharedSecretRequest) -> crate::Result { + self.0 + .run_mobile_plugin("shared_secret", payload) + .map_err(Into::into) + } + } diff --git a/src/models.rs b/src/models.rs index 42b1d21..ee8c20f 100644 --- a/src/models.rs +++ b/src/models.rs @@ -25,3 +25,22 @@ pub struct RemoveRequest { pub service: String, pub user: String, } + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SharedSecretRequest { + pub with_p256_pub_key: String, + pub extra_info: Option +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SharedSecretResponse { + pub shared_secret: String +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChaChaSharedSecret { + pub chacha_20_key: String +} \ No newline at end of file From 08a64d97ca9a7271093f1d539c3f85ed9560e0f8 Mon Sep 17 00:00:00 2001 From: 0x330a <92654767+0x330a@users.noreply.github.com> Date: Fri, 18 Apr 2025 21:25:13 +0700 Subject: [PATCH 02/33] feat: add shared pubkey retrieval command --- android/src/main/java/KeystorePlugin.kt | 131 ++++++++++++++---- build.rs | 2 +- .../commands/shared_secret_pub_key.toml | 13 ++ permissions/autogenerated/reference.md | 28 ++++ permissions/default.toml | 2 +- permissions/schemas/schema.json | 16 ++- src/commands.rs | 9 +- src/lib.rs | 3 +- src/mobile.rs | 6 + src/models.rs | 8 +- 10 files changed, 184 insertions(+), 34 deletions(-) create mode 100644 permissions/autogenerated/commands/shared_secret_pub_key.toml diff --git a/android/src/main/java/KeystorePlugin.kt b/android/src/main/java/KeystorePlugin.kt index a86c61f..0c93929 100644 --- a/android/src/main/java/KeystorePlugin.kt +++ b/android/src/main/java/KeystorePlugin.kt @@ -3,21 +3,18 @@ package app.tauri.keystore import android.app.Activity import android.content.Context import android.content.SharedPreferences -//import android.hardware.biometrics.BiometricPrompt +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import app.tauri.Logger import app.tauri.annotation.Command import app.tauri.annotation.InvokeArg import app.tauri.annotation.TauriPlugin -import app.tauri.Logger -import android.util.Base64 +import app.tauri.plugin.Invoke import app.tauri.plugin.JSObject import app.tauri.plugin.Plugin -import app.tauri.plugin.Invoke -import javax.crypto.KeyGenerator -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties -import androidx.core.content.ContextCompat -import org.komputing.khex.decode import org.komputing.khex.encode import java.math.BigInteger import java.security.* @@ -25,9 +22,7 @@ import java.security.spec.ECGenParameterSpec import java.security.spec.ECParameterSpec import java.security.spec.ECPoint import java.security.spec.ECPublicKeySpec -import javax.crypto.Cipher -import javax.crypto.KeyAgreement -import javax.crypto.SecretKey +import javax.crypto.* import javax.crypto.spec.GCMParameterSpec private const val KEY_ALIAS = "key_alias" @@ -212,7 +207,7 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { } } - private fun ensureP256Key(): Key? { + private fun ensureP256Key() { val keystore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } if (!keystore.containsAlias(KEY_AGREEMENT_ALIAS)) { val keyGenerator = @@ -220,7 +215,9 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { val keyGenParameterSpec = KeyGenParameterSpec.Builder( KEY_AGREEMENT_ALIAS, - KeyProperties.PURPOSE_AGREE_KEY or KeyProperties.PURPOSE_SIGN + // do all of the above (sign internal for verifying key integrity) + KeyProperties.PURPOSE_AGREE_KEY or + KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY ) .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) .build() @@ -228,31 +225,101 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { keyGenerator.initialize(keyGenParameterSpec) keyGenerator.generateKeyPair() } - - return keystore.getKey(KEY_AGREEMENT_ALIAS, null) } + @Command + fun shared_secret_pub_key(invoke: Invoke) { + // ensure we have generated the key + ensureP256Key() + + try { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + val certificate = keyStore.getCertificate(KEY_AGREEMENT_ALIAS) + val publicKey = certificate.publicKey + + // Convert public key to uncompressed format (04 + x + y) + val ecPublicKey = publicKey as java.security.interfaces.ECPublicKey + val x = ecPublicKey.w.affineX + val y = ecPublicKey.w.affineY + + // Convert to hex string with 04 prefix (uncompressed format) + val xHex = x.toString(16).padStart(64, '0') + val yHex = y.toString(16).padStart(64, '0') + val pubKeyHex = "04$xHex$yHex" + + // Return the public key + val response = JSObject() + response.put("pubKey", pubKeyHex) + invoke.resolve(response) + } catch (e: Exception) { + e.printStackTrace() + invoke.reject("Failed to get public key: ${e.message}") + } + } @Command fun shared_secret(invoke: Invoke) { val params = invoke.parseArgs(SharedSecretRequest::class.java) // ensure we have generated the key - val key = ensureP256Key() - val agreement = getAgreement() + ensureP256Key() + val cryptoObject = BiometricPrompt.CryptoObject(getSignature()) - Logger.debug("got param: ${params.withP256PubKey}") + // Create biometric prompt + val executor = ContextCompat.getMainExecutor(activity) + val biometricPrompt = + BiometricPrompt(activity as androidx.fragment.app.FragmentActivity, executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + try { + // Get the Signature from the authentication result. + val authSig = result.cryptoObject?.signature + ?: throw IllegalStateException("Signature not available after auth") + + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + val certificate = keyStore.getCertificate(KEY_AGREEMENT_ALIAS) + + // generate a random message + val message = SecureRandom.getSeed(32) + // update and output the signature previously initialized for signing + authSig.update(message) + val outputSig = authSig.sign() + + // init in verify mode and check the signature matches the key agreement certificate + authSig.initVerify(certificate) + authSig.update(message) + authSig.verify(outputSig) + + // generate the shared secret from agreement + val agreement = getAgreement() + agreement.doPhase(getPublicKeyFromHex(params.withP256PubKey), true) + val secret = agreement.generateSecret() + invoke.resolveObject(SharedSecretResponse(encode(secret, prefix = ""))) + } catch (e: Exception) { + e.printStackTrace() + Logger.error("Encryption failed: ${e.message}") + } + } + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + invoke.reject("Authentication error: $errorCode") + } + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + invoke.reject("Authentication failed") + } + }) - Logger.debug("got key: $key") - Logger.debug("got agreement: $agreement") - Logger.debug("trying to use:") + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle("Authenticate to Store Secret") + .setSubtitle("Biometric authentication is required") + .setNegativeButtonText("Cancel") + .build() - agreement.init(key) - agreement.doPhase(getPublicKeyFromHex(params.withP256PubKey), true) - val secret = agreement.generateSecret() - invoke.resolveObject(SharedSecretResponse(encode(secret, prefix = ""))) + biometricPrompt.authenticate(promptInfo, cryptoObject) } private fun getAgreement(): KeyAgreement { @@ -265,12 +332,22 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { return keyAgreement } + private fun getSignature(): Signature { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + + val secretKey = keyStore.getKey(KEY_AGREEMENT_ALIAS, null) + + val sig = Signature.getInstance("ECDSA") + sig.initSign(secretKey as PrivateKey) + return sig + } + // Prepares and returns a Cipher instance for encryption using the key from the Keystore. private fun getEncryptionCipher(): Cipher { val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } val secretKey = keyStore.getKey(KEY_ALIAS, null) - val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val cipher = Cipher.getInstance( "AES/GCM/NoPadding") cipher.init(Cipher.ENCRYPT_MODE, secretKey) return cipher } diff --git a/build.rs b/build.rs index 7c28f6c..e4f1c13 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,4 @@ -const COMMANDS: &[&str] = &["remove", "retrieve", "store", "shared_secret"]; +const COMMANDS: &[&str] = &["remove", "retrieve", "store", "shared_secret", "shared_secret_pub_key"]; fn main() { tauri_plugin::Builder::new(COMMANDS) diff --git a/permissions/autogenerated/commands/shared_secret_pub_key.toml b/permissions/autogenerated/commands/shared_secret_pub_key.toml new file mode 100644 index 0000000..ecbf25a --- /dev/null +++ b/permissions/autogenerated/commands/shared_secret_pub_key.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-shared-secret-pub-key" +description = "Enables the shared_secret_pub_key command without any pre-configured scope." +commands.allow = ["shared_secret_pub_key"] + +[[permission]] +identifier = "deny-shared-secret-pub-key" +description = "Denies the shared_secret_pub_key command without any pre-configured scope." +commands.deny = ["shared_secret_pub_key"] diff --git a/permissions/autogenerated/reference.md b/permissions/autogenerated/reference.md index 4285034..0abe0fa 100644 --- a/permissions/autogenerated/reference.md +++ b/permissions/autogenerated/reference.md @@ -7,6 +7,8 @@ Default permissions for the plugin - `allow-remove` - `allow-retrieve` - `allow-store` +- `allow-shared-secret` +- `allow-shared-secret-pub-key` ## Permission Table @@ -98,6 +100,32 @@ Denies the shared_secret command without any pre-configured scope. +`keystore:allow-shared-secret-pub-key` + + + + +Enables the shared_secret_pub_key command without any pre-configured scope. + + + + + + + +`keystore:deny-shared-secret-pub-key` + + + + +Denies the shared_secret_pub_key command without any pre-configured scope. + + + + + + + `keystore:allow-store` diff --git a/permissions/default.toml b/permissions/default.toml index 506258c..b9150f7 100644 --- a/permissions/default.toml +++ b/permissions/default.toml @@ -1,3 +1,3 @@ [default] description = "Default permissions for the plugin" -permissions = ["allow-remove", "allow-retrieve", "allow-store"] +permissions = ["allow-remove", "allow-retrieve", "allow-store", "allow-shared-secret", "allow-shared-secret-pub-key"] diff --git a/permissions/schemas/schema.json b/permissions/schemas/schema.json index c39a60d..64accfc 100644 --- a/permissions/schemas/schema.json +++ b/permissions/schemas/schema.json @@ -330,6 +330,18 @@ "const": "deny-shared-secret", "markdownDescription": "Denies the shared_secret command without any pre-configured scope." }, + { + "description": "Enables the shared_secret_pub_key command without any pre-configured scope.", + "type": "string", + "const": "allow-shared-secret-pub-key", + "markdownDescription": "Enables the shared_secret_pub_key command without any pre-configured scope." + }, + { + "description": "Denies the shared_secret_pub_key command without any pre-configured scope.", + "type": "string", + "const": "deny-shared-secret-pub-key", + "markdownDescription": "Denies the shared_secret_pub_key command without any pre-configured scope." + }, { "description": "Enables the store command without any pre-configured scope.", "type": "string", @@ -343,10 +355,10 @@ "markdownDescription": "Denies the store command without any pre-configured scope." }, { - "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-remove`\n- `allow-retrieve`\n- `allow-store`", + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-remove`\n- `allow-retrieve`\n- `allow-store`\n- `allow-shared-secret`\n- `allow-shared-secret-pub-key`", "type": "string", "const": "default", - "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-remove`\n- `allow-retrieve`\n- `allow-store`" + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-remove`\n- `allow-retrieve`\n- `allow-store`\n- `allow-shared-secret`\n- `allow-shared-secret-pub-key`" } ] } diff --git a/src/commands.rs b/src/commands.rs index c31c305..d32da60 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -30,6 +30,13 @@ pub(crate) async fn remove( app.keystore().remove(payload) } +#[command] +pub(crate) async fn shared_secret_pub_key( + app: AppHandle, +) -> crate::Result { + app.keystore().shared_secret_pub_key() +} + #[command] pub(crate) async fn shared_secret ( app: AppHandle, @@ -61,4 +68,4 @@ pub(crate) async fn shared_secret ( Ok(ChaChaSharedSecret { chacha_20_key: okm.encode_hex(), }) -} \ No newline at end of file +} diff --git a/src/lib.rs b/src/lib.rs index 2fdb603..ee59224 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -35,7 +35,8 @@ pub fn init() -> TauriPlugin { commands::remove, commands::retrieve, commands::store, - commands::shared_secret + commands::shared_secret, + commands::shared_secret_pub_key ]) .setup(|app, api| { let keystore = mobile::init(app, api)?; diff --git a/src/mobile.rs b/src/mobile.rs index 43a3c13..14d0a0b 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -49,4 +49,10 @@ impl Keystore { .map_err(Into::into) } + pub fn shared_secret_pub_key(&self) -> crate::Result { + self.0 + .run_mobile_plugin("shared_secret_pub_key", ()) + .map_err(Into::into) + } + } diff --git a/src/models.rs b/src/models.rs index ee8c20f..03590a6 100644 --- a/src/models.rs +++ b/src/models.rs @@ -26,6 +26,12 @@ pub struct RemoveRequest { pub user: String, } +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PubKeyResponse { + pub pub_key: String +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct SharedSecretRequest { @@ -43,4 +49,4 @@ pub struct SharedSecretResponse { #[serde(rename_all = "camelCase")] pub struct ChaChaSharedSecret { pub chacha_20_key: String -} \ No newline at end of file +} From 5cfb8c86920b4c37bc500f51d805396ca808848b Mon Sep 17 00:00:00 2001 From: 0x330a <92654767+0x330a@users.noreply.github.com> Date: Fri, 18 Apr 2025 22:10:53 +0700 Subject: [PATCH 03/33] fix: enable digests for ECDSA signature --- android/src/main/java/KeystorePlugin.kt | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/android/src/main/java/KeystorePlugin.kt b/android/src/main/java/KeystorePlugin.kt index 0c93929..e23b95f 100644 --- a/android/src/main/java/KeystorePlugin.kt +++ b/android/src/main/java/KeystorePlugin.kt @@ -220,6 +220,7 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY ) .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) + .setDigests(KeyProperties.DIGEST_SHA256) .build() keyGenerator.initialize(keyGenParameterSpec) @@ -263,7 +264,8 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { // ensure we have generated the key ensureP256Key() - val cryptoObject = BiometricPrompt.CryptoObject(getSignature()) + val signature = getSignature() + Logger.debug("initiated cryptoObject") // Create biometric prompt val executor = ContextCompat.getMainExecutor(activity) @@ -274,8 +276,7 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { super.onAuthenticationSucceeded(result) try { // Get the Signature from the authentication result. - val authSig = result.cryptoObject?.signature - ?: throw IllegalStateException("Signature not available after auth") + val authSig = signature val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } val certificate = keyStore.getCertificate(KEY_AGREEMENT_ALIAS) @@ -285,20 +286,22 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { // update and output the signature previously initialized for signing authSig.update(message) val outputSig = authSig.sign() + Logger.debug("signed message") // init in verify mode and check the signature matches the key agreement certificate authSig.initVerify(certificate) + Logger.debug("initVerify success") authSig.update(message) authSig.verify(outputSig) + // generate the shared secret from agreement val agreement = getAgreement() agreement.doPhase(getPublicKeyFromHex(params.withP256PubKey), true) val secret = agreement.generateSecret() invoke.resolveObject(SharedSecretResponse(encode(secret, prefix = ""))) } catch (e: Exception) { - e.printStackTrace() - Logger.error("Encryption failed: ${e.message}") + invoke.reject("Shared secret failed: ${e.message}") } } @@ -314,12 +317,16 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { }) val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle("Authenticate to Store Secret") + .setTitle("Authenticate to Compute Shared Secret") .setSubtitle("Biometric authentication is required") .setNegativeButtonText("Cancel") .build() - biometricPrompt.authenticate(promptInfo, cryptoObject) + try { + biometricPrompt.authenticate(promptInfo) + } catch (e: Error) { + Logger.error("couldn't start biometric?: ${e.message}") + } } private fun getAgreement(): KeyAgreement { @@ -337,8 +344,10 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { val secretKey = keyStore.getKey(KEY_AGREEMENT_ALIAS, null) - val sig = Signature.getInstance("ECDSA") + val sig = Signature.getInstance("SHA256withECDSA") + Logger.debug("initializing signing...") sig.initSign(secretKey as PrivateKey) + Logger.debug("initialized signing!") return sig } From 17da93730a0243e5f48c7560a93205ab38a8fd16 Mon Sep 17 00:00:00 2001 From: 0x330a <92654767+0x330a@users.noreply.github.com> Date: Wed, 23 Apr 2025 22:48:30 +0700 Subject: [PATCH 04/33] feat: multiple shared secret inputs and forcing the salt to be provided, alongside optional extra info --- android/src/main/java/KeystorePlugin.kt | 20 +++++++++---- src/commands.rs | 37 ++++++++++++++----------- src/models.rs | 29 +++++++++++++++++-- 3 files changed, 61 insertions(+), 25 deletions(-) diff --git a/android/src/main/java/KeystorePlugin.kt b/android/src/main/java/KeystorePlugin.kt index e23b95f..9bbc86f 100644 --- a/android/src/main/java/KeystorePlugin.kt +++ b/android/src/main/java/KeystorePlugin.kt @@ -45,11 +45,11 @@ class RetrieveRequest { @InvokeArg class SharedSecretRequest { - lateinit var withP256PubKey: String + lateinit var withP256PubKeys: List } data class SharedSecretResponse( - val sharedSecret: String + val sharedSecrets: List ) @TauriPlugin @@ -295,11 +295,19 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { authSig.verify(outputSig) - // generate the shared secret from agreement + // generate the shared secrets from agreement val agreement = getAgreement() - agreement.doPhase(getPublicKeyFromHex(params.withP256PubKey), true) - val secret = agreement.generateSecret() - invoke.resolveObject(SharedSecretResponse(encode(secret, prefix = ""))) + val sharedSecrets = mutableListOf() + + // Process each public key + for (pubKey in params.withP256PubKeys) { + agreement.init(keyStore.getKey(KEY_AGREEMENT_ALIAS, null)) + agreement.doPhase(getPublicKeyFromHex(pubKey), true) + val secret = agreement.generateSecret() + sharedSecrets.add(encode(secret, prefix = "")) + } + + invoke.resolveObject(SharedSecretResponse(sharedSecrets)) } catch (e: Exception) { invoke.reject("Shared secret failed: ${e.message}") } diff --git a/src/commands.rs b/src/commands.rs index d32da60..b415914 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -43,29 +43,34 @@ pub(crate) async fn shared_secret ( payload: SharedSecretRequest ) -> crate::Result { - let extra_info = payload.extra_info.clone(); + // Create the salt + let salt_hasher = Sha512::new(); + let mut salt = salt_hasher + .chain_update(payload.salt.as_bytes()); + if let Some(extra) = &payload.extra_info { + salt = salt.chain_update(extra.as_bytes()); + } + let salt_value = salt.finalize(); let shared_secret_result = app.keystore().shared_secret(payload)?; - let salt_hasher = Sha512::new(); + // Process each shared secret + let mut chacha_20_keys = Vec::new(); + for shared_secret in &shared_secret_result.shared_secrets { + let ikm: Vec = hex::decode(shared_secret).map_err(|e| { + crate::Error::Io(std::io::Error::new(std::io::ErrorKind::Other, e)) + })?; - let ikm: Vec = hex::decode(&shared_secret_result.shared_secret).map_err(|e| { - crate::Error::Io(std::io::Error::new(std::io::ErrorKind::Other, e)) - })?; + let hk = Hkdf::::new(Some(&salt_value), &ikm); + let mut okm = [0u8;32]; + hk.expand(&[], &mut okm).map_err(|_| { + crate::Error::Generic("invalid length".to_string()) + })?; - let mut salt = salt_hasher - .chain_update(b"tauri-plugin-keystore"); - if let Some(extra) = extra_info { - salt = salt.chain_update(extra.as_bytes()); + chacha_20_keys.push(okm.encode_hex()); } - let hk = Hkdf::::new(Some(&salt.finalize()), &ikm); - let mut okm = [0u8;32]; - hk.expand(&[], &mut okm).map_err(|e| { - crate::Error::Generic("invalid length".to_string()) - })?; - Ok(ChaChaSharedSecret { - chacha_20_key: okm.encode_hex(), + chacha_20_keys, }) } diff --git a/src/models.rs b/src/models.rs index 03590a6..4c50f26 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,52 +1,75 @@ use serde::{Deserialize, Serialize}; +/// Request to store a value in the keystore #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StoreRequest { + /// The value to store in the keystore pub value: String, } +/// Request to retrieve a value from the keystore #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct RetrieveRequest { + /// The service identifier pub service: String, + /// The user identifier pub user: String, } +/// Response containing the retrieved value from the keystore #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct RetrieveResponse { + /// The retrieved value, if it exists pub value: Option, } +/// Request to remove a value from the keystore #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct RemoveRequest { + /// The service identifier pub service: String, + /// The user identifier pub user: String, } +/// Response containing the public key #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct PubKeyResponse { + /// The public key in Hex format pub pub_key: String } + +/// Request to generate shared secrets with P-256 public keys #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct SharedSecretRequest { - pub with_p256_pub_key: String, + /// Vector of P-256 public keys to generate shared secrets with + pub with_p256_pub_keys: Vec, + + /// The salt value used in the HKDF key derivation process + pub salt: String, + /// Optional additional information for the shared secret generation pub extra_info: Option } +/// Response containing the generated shared secrets #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct SharedSecretResponse { - pub shared_secret: String + /// List of generated shared secrets + pub shared_secrets: Vec } +/// Contains ChaCha20 keys derived from shared secrets #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ChaChaSharedSecret { - pub chacha_20_key: String + /// List of ChaCha20 keys in hex format + pub chacha_20_keys: Vec } From b9ee478759d7a93ca8880545ccd82d5d83e52f71 Mon Sep 17 00:00:00 2001 From: 0x330a <92654767+0x330a@users.noreply.github.com> Date: Tue, 29 Apr 2025 17:01:25 +0700 Subject: [PATCH 05/33] feat: start guest js functions for shared secret keys --- guest-js/index.ts | 56 ++++++++++++++++++++++++++++++----------------- package.json | 5 +++-- pnpm-lock.yaml | 17 ++++++++++++++ 3 files changed, 56 insertions(+), 22 deletions(-) diff --git a/guest-js/index.ts b/guest-js/index.ts index 0a76369..46f95ce 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -1,30 +1,46 @@ -import { invoke } from "@tauri-apps/api/core"; +import {invoke} from "@tauri-apps/api/core"; +import {p256} from "@noble/curves/p256"; export async function store(value: string): Promise { - return await invoke("plugin:keystore|store", { - payload: { - value, - }, - }); + return await invoke("plugin:keystore|store", { + payload: { + value, + }, + }); } export async function retrieve( - service: string, - user: string + service: string, + user: string ): Promise { - return await invoke<{ value?: string }>("plugin:keystore|retrieve", { - payload: { - service, - user, - }, - }).then((r) => (r.value ? r.value : null)); + return await invoke<{ value?: string }>("plugin:keystore|retrieve", { + payload: { + service, + user, + }, + }).then((r) => (r.value ? r.value : null)); } export async function remove(service: string, user: string) { - return await invoke("plugin:keystore|remove", { - payload: { - service, - user, - }, - }); + return await invoke("plugin:keystore|remove", { + payload: { + service, + user, + }, + }); } + +export async function sharedSecretPubKey() { + return await invoke("plugin:keystore|shared_secret_pub_key") + .then((pubkey) => p256.ProjectivePoint.fromHex(pubkey)); +} + +export async function sharedSecret(pubHex: string, salt: string, extraInfo?: string): Promise { + return await invoke("plugin:keystore|shared_secret", { + payload: { + withP256PubKey: pubHex, + extraInfo, + salt + }, + }); +} \ No newline at end of file diff --git a/package.json b/package.json index 80bdae2..3ec8cae 100644 --- a/package.json +++ b/package.json @@ -22,13 +22,14 @@ "pretest": "pnpm build" }, "dependencies": { + "@noble/curves": "^1.9.0", "@tauri-apps/api": ">=2.0.0-beta.6" }, "devDependencies": { "@rollup/plugin-typescript": "^11.1.6", "rollup": "^4.9.6", - "typescript": "^5.3.3", - "tslib": "^2.6.2" + "tslib": "^2.6.2", + "typescript": "^5.3.3" }, "license": "Apache-2.0", "repository": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8144a6..aec5f01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@noble/curves': + specifier: ^1.9.0 + version: 1.9.0 '@tauri-apps/api': specifier: '>=2.0.0-beta.6' version: 2.2.0 @@ -27,6 +30,14 @@ importers: packages: + '@noble/curves@1.9.0': + resolution: {integrity: sha512-7YDlXiNMdO1YZeH6t/kvopHHbIZzlxrCV9WLqCY6QhcXOoXiNCMDqJIglZ9Yjx5+w7Dz30TITFrlTjnRg7sKEg==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@rollup/plugin-typescript@11.1.6': resolution: {integrity: sha512-R92yOmIACgYdJ7dJ97p4K69I8gg6IEHt8M7dUBxN3W6nrO8uUxX5ixl0yU/N3aZTi8WhPuICvOHXQvF6FaykAA==} engines: {node: '>=14.0.0'} @@ -200,6 +211,12 @@ packages: snapshots: + '@noble/curves@1.9.0': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + '@rollup/plugin-typescript@11.1.6(rollup@4.34.6)(tslib@2.8.1)(typescript@5.7.3)': dependencies: '@rollup/pluginutils': 5.1.4(rollup@4.34.6) From a6a3018992d0fc6a54b96a0ca772a054ca6ccaf9 Mon Sep 17 00:00:00 2001 From: 0x330a <92654767+0x330a@users.noreply.github.com> Date: Thu, 8 May 2025 20:17:33 +0700 Subject: [PATCH 06/33] feat: add unencrypted prefs storage and depends on config now --- android/src/main/java/Example.kt | 10 -- android/src/main/java/KeystorePlugin.kt | 93 ++++++++++--------- build.rs | 2 +- .../commands/retrieve_unencrypted.toml | 13 +++ .../commands/store_unencrypted.toml | 13 +++ permissions/autogenerated/reference.md | 54 +++++++++++ permissions/default.toml | 2 +- permissions/schemas/schema.json | 28 +++++- src/commands.rs | 16 ++++ src/lib.rs | 15 ++- src/mobile.rs | 13 +++ src/models.rs | 14 ++- 12 files changed, 204 insertions(+), 69 deletions(-) delete mode 100644 android/src/main/java/Example.kt create mode 100644 permissions/autogenerated/commands/retrieve_unencrypted.toml create mode 100644 permissions/autogenerated/commands/store_unencrypted.toml diff --git a/android/src/main/java/Example.kt b/android/src/main/java/Example.kt deleted file mode 100644 index 1950ad5..0000000 --- a/android/src/main/java/Example.kt +++ /dev/null @@ -1,10 +0,0 @@ -package app.tauri.keystore - -import android.util.Log - -class Example { - fun pong(value: String): String { - Log.i("Pong", value) - return value - } -} diff --git a/android/src/main/java/KeystorePlugin.kt b/android/src/main/java/KeystorePlugin.kt index 9bbc86f..78d2af1 100644 --- a/android/src/main/java/KeystorePlugin.kt +++ b/android/src/main/java/KeystorePlugin.kt @@ -6,8 +6,10 @@ import android.content.SharedPreferences import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Base64 +import android.webkit.WebView import androidx.biometric.BiometricPrompt import androidx.core.content.ContextCompat +import androidx.core.content.edit import app.tauri.Logger import app.tauri.annotation.Command import app.tauri.annotation.InvokeArg @@ -30,17 +32,28 @@ private const val KEY_AGREEMENT_ALIAS = "key_agreement_alias" private const val ANDROID_KEYSTORE = "AndroidKeyStore" private const val SHARED_PREFERENCES_NAME = "secure_storage" +class KeystoreConfig { + var unencryptedStoreName: String? = null +} + @InvokeArg class StoreRequest { + lateinit var key: String lateinit var value: String - // TODO: use this instead? - // var value: String? = null } @InvokeArg class RetrieveRequest { - lateinit var service: String - lateinit var user: String + lateinit var key: String +} + +data class RetrieveResponse( + val value: String? +) + +@InvokeArg +class RemoveRequest { + lateinit var key: String } @InvokeArg @@ -54,7 +67,32 @@ data class SharedSecretResponse( @TauriPlugin class KeystorePlugin(private val activity: Activity) : Plugin(activity) { - private val implementation = Example() + + lateinit var unencryptedStoreName: String + + + override fun load(webView: WebView) { + super.load(webView) + getConfig(KeystoreConfig::class.java).let { config -> + unencryptedStoreName = config.unencryptedStoreName ?: "unencrypted_store" + } + } + + @Command + fun store_unencrypted(invoke: Invoke) { + val args = invoke.parseArgs(StoreRequest::class.java) + activity.getSharedPreferences(unencryptedStoreName, Context.MODE_PRIVATE).edit { + putString(args.key, args.value) + } + invoke.resolve() + } + + @Command + fun retrieve_unencrypted(invoke: Invoke) { + val args = invoke.parseArgs(RetrieveRequest::class.java) + val value = activity.getSharedPreferences(unencryptedStoreName, Context.MODE_PRIVATE).getString(args.key, null) + invoke.resolveObject(RetrieveResponse(value)) + } @Command fun store(invoke: Invoke) { @@ -86,7 +124,7 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { authCipher.doFinal(storeRequest.value.toByteArray()) val iv = authCipher.iv // Capture the initialization vector. // Store the ciphertext and IV. - storeCiphertext(iv, ciphertext) + storeCiphertext(storeRequest.key, iv, ciphertext) Logger.info("Secret stored securely") } catch (e: Exception) { e.printStackTrace() @@ -113,39 +151,6 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { biometricPrompt.authenticate(promptInfo, cryptoObject) - // Unlock -// val spec = javax.crypto.spec.GCMParameterSpec(123, iv) -// cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) - -// val biometricPrompt = BiometricPrompt( -// activity as androidx.fragment.app.FragmentActivity, -// object : BiometricPrompt.AuthenticationCallback() { -// override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { -// super.onAuthenticationSucceeded(result) -// try { -// // Use the cipher from the CryptoObject to decrypt the ciphertext. -// val decryptedBytes = result.cryptoObject?.cipher?.doFinal(ciphertext) -// val password = decryptedBytes?.toString(StandardCharsets.UTF_8) -// if (password != null) { -// onDecrypted(password) -// } else { -// onError("Decryption failed") -// } -// } catch (e: Exception) { -// onError("Decryption exception: ${e.message}") -// } -// } -// override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { -// super.onAuthenticationError(errorCode, errString) -// onError("Authentication error: $errString") -// } -// override fun onAuthenticationFailed() { -// super.onAuthenticationFailed() -// onError("Authentication failed") -// } -// } -// ) - invoke.resolve() } @@ -370,14 +375,14 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { } // Stores the IV and ciphertext in SharedPreferences. - private fun storeCiphertext(iv: ByteArray, ciphertext: ByteArray) { + private fun storeCiphertext(key: String, iv: ByteArray, ciphertext: ByteArray) { val prefs: SharedPreferences = activity.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE) val editor = prefs.edit() val ivEncoded = Base64.encodeToString(iv, Base64.DEFAULT) val ctEncoded = Base64.encodeToString(ciphertext, Base64.DEFAULT) - editor.putString("iv", ivEncoded) - editor.putString("ciphertext", ctEncoded) + editor.putString("iv-$key", ivEncoded) + editor.putString("ciphertext-$key", ctEncoded) editor.apply() } @@ -466,9 +471,9 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { @Command fun remove(invoke: Invoke) { + val request = invoke.parseArgs(RemoveRequest::class.java) try { - val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } - keyStore.deleteEntry(KEY_ALIAS) + invoke.resolve() } catch (e: Exception) { invoke.reject("Could not delete entry from KeyStore: ${e.localizedMessage}") diff --git a/build.rs b/build.rs index e4f1c13..d0e5017 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,4 @@ -const COMMANDS: &[&str] = &["remove", "retrieve", "store", "shared_secret", "shared_secret_pub_key"]; +const COMMANDS: &[&str] = &["remove", "retrieve", "store", "shared_secret", "shared_secret_pub_key", "store_unencrypted", "retrieve_unencrypted"]; fn main() { tauri_plugin::Builder::new(COMMANDS) diff --git a/permissions/autogenerated/commands/retrieve_unencrypted.toml b/permissions/autogenerated/commands/retrieve_unencrypted.toml new file mode 100644 index 0000000..c309b11 --- /dev/null +++ b/permissions/autogenerated/commands/retrieve_unencrypted.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-retrieve-unencrypted" +description = "Enables the retrieve_unencrypted command without any pre-configured scope." +commands.allow = ["retrieve_unencrypted"] + +[[permission]] +identifier = "deny-retrieve-unencrypted" +description = "Denies the retrieve_unencrypted command without any pre-configured scope." +commands.deny = ["retrieve_unencrypted"] diff --git a/permissions/autogenerated/commands/store_unencrypted.toml b/permissions/autogenerated/commands/store_unencrypted.toml new file mode 100644 index 0000000..2829fb5 --- /dev/null +++ b/permissions/autogenerated/commands/store_unencrypted.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-store-unencrypted" +description = "Enables the store_unencrypted command without any pre-configured scope." +commands.allow = ["store_unencrypted"] + +[[permission]] +identifier = "deny-store-unencrypted" +description = "Denies the store_unencrypted command without any pre-configured scope." +commands.deny = ["store_unencrypted"] diff --git a/permissions/autogenerated/reference.md b/permissions/autogenerated/reference.md index 0abe0fa..4a4a47e 100644 --- a/permissions/autogenerated/reference.md +++ b/permissions/autogenerated/reference.md @@ -9,6 +9,8 @@ Default permissions for the plugin - `allow-store` - `allow-shared-secret` - `allow-shared-secret-pub-key` +- `allow-store-unencrypted` +- `allow-retrieve-unencrypted` ## Permission Table @@ -74,6 +76,32 @@ Denies the retrieve command without any pre-configured scope. +`keystore:allow-retrieve-unencrypted` + + + + +Enables the retrieve_unencrypted command without any pre-configured scope. + + + + + + + +`keystore:deny-retrieve-unencrypted` + + + + +Denies the retrieve_unencrypted command without any pre-configured scope. + + + + + + + `keystore:allow-shared-secret` @@ -146,6 +174,32 @@ Enables the store command without any pre-configured scope. Denies the store command without any pre-configured scope. + + + + + + +`keystore:allow-store-unencrypted` + + + + +Enables the store_unencrypted command without any pre-configured scope. + + + + + + + +`keystore:deny-store-unencrypted` + + + + +Denies the store_unencrypted command without any pre-configured scope. + diff --git a/permissions/default.toml b/permissions/default.toml index b9150f7..c29ff3c 100644 --- a/permissions/default.toml +++ b/permissions/default.toml @@ -1,3 +1,3 @@ [default] description = "Default permissions for the plugin" -permissions = ["allow-remove", "allow-retrieve", "allow-store", "allow-shared-secret", "allow-shared-secret-pub-key"] +permissions = ["allow-remove", "allow-retrieve", "allow-store", "allow-shared-secret", "allow-shared-secret-pub-key", "allow-store-unencrypted", "allow-retrieve-unencrypted"] diff --git a/permissions/schemas/schema.json b/permissions/schemas/schema.json index 64accfc..8fd6a26 100644 --- a/permissions/schemas/schema.json +++ b/permissions/schemas/schema.json @@ -318,6 +318,18 @@ "const": "deny-retrieve", "markdownDescription": "Denies the retrieve command without any pre-configured scope." }, + { + "description": "Enables the retrieve_unencrypted command without any pre-configured scope.", + "type": "string", + "const": "allow-retrieve-unencrypted", + "markdownDescription": "Enables the retrieve_unencrypted command without any pre-configured scope." + }, + { + "description": "Denies the retrieve_unencrypted command without any pre-configured scope.", + "type": "string", + "const": "deny-retrieve-unencrypted", + "markdownDescription": "Denies the retrieve_unencrypted command without any pre-configured scope." + }, { "description": "Enables the shared_secret command without any pre-configured scope.", "type": "string", @@ -355,10 +367,22 @@ "markdownDescription": "Denies the store command without any pre-configured scope." }, { - "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-remove`\n- `allow-retrieve`\n- `allow-store`\n- `allow-shared-secret`\n- `allow-shared-secret-pub-key`", + "description": "Enables the store_unencrypted command without any pre-configured scope.", + "type": "string", + "const": "allow-store-unencrypted", + "markdownDescription": "Enables the store_unencrypted command without any pre-configured scope." + }, + { + "description": "Denies the store_unencrypted command without any pre-configured scope.", + "type": "string", + "const": "deny-store-unencrypted", + "markdownDescription": "Denies the store_unencrypted command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-remove`\n- `allow-retrieve`\n- `allow-store`\n- `allow-shared-secret`\n- `allow-shared-secret-pub-key`\n- `allow-store-unencrypted`\n- `allow-retrieve-unencrypted`", "type": "string", "const": "default", - "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-remove`\n- `allow-retrieve`\n- `allow-store`\n- `allow-shared-secret`\n- `allow-shared-secret-pub-key`" + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-remove`\n- `allow-retrieve`\n- `allow-store`\n- `allow-shared-secret`\n- `allow-shared-secret-pub-key`\n- `allow-store-unencrypted`\n- `allow-retrieve-unencrypted`" } ] } diff --git a/src/commands.rs b/src/commands.rs index b415914..695df79 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -6,6 +6,14 @@ use tauri::{command, AppHandle, Runtime}; use crate::models::*; use crate::KeystoreExt; +#[command] +pub(crate) async fn store_unencrypted( + app: AppHandle, + payload: StoreRequest +) -> crate::Result<()> { + app.keystore().store_unencrypted(payload) +} + #[command] pub(crate) async fn store( app: AppHandle, @@ -14,6 +22,14 @@ pub(crate) async fn store( app.keystore().store(payload) } +#[command] +pub(crate) async fn retrieve_unencrypted( + app: AppHandle, + payload: RetrieveRequest, +) -> crate::Result { + app.keystore().retrieve_unencrypted(payload) +} + #[command] pub(crate) async fn retrieve( app: AppHandle, diff --git a/src/lib.rs b/src/lib.rs index ee59224..bb5426e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,11 @@ #![cfg(mobile)] +use serde::{Deserialize, Serialize}; use tauri::{ plugin::{Builder, TauriPlugin}, Manager, Runtime, }; - +use tauri::plugin::PluginApi; pub use models::*; mod mobile; @@ -28,17 +29,25 @@ impl> KeystoreExt for T { } } +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Config { + pub unencrypted_store_name: Option +} + /// Initializes the plugin. -pub fn init() -> TauriPlugin { +pub fn init() -> TauriPlugin { Builder::new("keystore") .invoke_handler(tauri::generate_handler![ + commands::store_unencrypted, + commands::retrieve_unencrypted, commands::remove, commands::retrieve, commands::store, commands::shared_secret, commands::shared_secret_pub_key ]) - .setup(|app, api| { + .setup(|app, api: PluginApi| { let keystore = mobile::init(app, api)?; app.manage(keystore); Ok(()) diff --git a/src/mobile.rs b/src/mobile.rs index 14d0a0b..1e485a5 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -25,12 +25,25 @@ pub fn init( pub struct Keystore(PluginHandle); impl Keystore { + + pub fn store_unencrypted(&self, payload: StoreRequest) -> crate::Result<()> { + self.0 + .run_mobile_plugin("store_unencrypted", payload) + .map_err(Into::into) + } + pub fn store(&self, payload: StoreRequest) -> crate::Result<()> { self.0 .run_mobile_plugin("store", payload) .map_err(Into::into) } + pub fn retrieve_unencrypted(&self, payload: RetrieveRequest) -> crate::Result { + self.0 + .run_mobile_plugin("retrieve_unencrypted", payload) + .map_err(Into::into) + } + pub fn retrieve(&self, payload: RetrieveRequest) -> crate::Result { self.0 .run_mobile_plugin("retrieve", payload) diff --git a/src/models.rs b/src/models.rs index 4c50f26..4f5877e 100644 --- a/src/models.rs +++ b/src/models.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StoreRequest { + /// The key to store against + pub key: String, /// The value to store in the keystore pub value: String, } @@ -12,10 +14,8 @@ pub struct StoreRequest { #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct RetrieveRequest { - /// The service identifier - pub service: String, - /// The user identifier - pub user: String, + /// The key to retrieve by + pub key: String } /// Response containing the retrieved value from the keystore @@ -30,10 +30,8 @@ pub struct RetrieveResponse { #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct RemoveRequest { - /// The service identifier - pub service: String, - /// The user identifier - pub user: String, + /// The key to remove + pub key: String } /// Response containing the public key From 9a6faaafaf8d394b1101f76058b42dc05d1f0741 Mon Sep 17 00:00:00 2001 From: 0x330a <92654767+0x330a@users.noreply.github.com> Date: Wed, 14 May 2025 21:49:37 +0700 Subject: [PATCH 07/33] feat: router config for proper builds, trying rpid --- android/src/main/java/KeystorePlugin.kt | 4 +--- src/lib.rs | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/KeystorePlugin.kt b/android/src/main/java/KeystorePlugin.kt index 78d2af1..85a028f 100644 --- a/android/src/main/java/KeystorePlugin.kt +++ b/android/src/main/java/KeystorePlugin.kt @@ -32,9 +32,7 @@ private const val KEY_AGREEMENT_ALIAS = "key_agreement_alias" private const val ANDROID_KEYSTORE = "AndroidKeyStore" private const val SHARED_PREFERENCES_NAME = "secure_storage" -class KeystoreConfig { - var unencryptedStoreName: String? = null -} +class KeystoreConfig @JvmOverloads constructor(val unencryptedStoreName: String = "unencrypted") @InvokeArg class StoreRequest { diff --git a/src/lib.rs b/src/lib.rs index bb5426e..adab0b6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,7 +29,7 @@ impl> KeystoreExt for T { } } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct Config { pub unencrypted_store_name: Option From 3c1d3d723d17ac5674ffbf5af66a6521f48da718 Mon Sep 17 00:00:00 2001 From: 0x330a <92654767+0x330a@users.noreply.github.com> Date: Wed, 28 May 2025 15:11:16 +0700 Subject: [PATCH 08/33] refactor: index js updates to use latest rust and mobile --- guest-js/index.ts | 34 ++++++++++++++++++++++++---------- package.json | 6 +++--- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/guest-js/index.ts b/guest-js/index.ts index 46f95ce..5420e2a 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -1,22 +1,36 @@ import {invoke} from "@tauri-apps/api/core"; import {p256} from "@noble/curves/p256"; -export async function store(value: string): Promise { +export async function storePlaintext(key: string, value: string): Promise { + return await invoke("plugin:keystore|store_unencrypted", { + payload: { + key, + value, + }, + }); +} + +export async function store(key: string, value: string): Promise { return await invoke("plugin:keystore|store", { payload: { + key, value, }, }); } -export async function retrieve( - service: string, - user: string -): Promise { +export async function retrievePlaintext(key: string) : Promise { + return await invoke<{value ?: string }>("plugin:keystore|retrieve_unencrypted", { + payload: { + key + } + }).then((r) => (r.value ? r.value : null)); +} + +export async function retrieve(key: string): Promise { return await invoke<{ value?: string }>("plugin:keystore|retrieve", { payload: { - service, - user, + key }, }).then((r) => (r.value ? r.value : null)); } @@ -35,10 +49,10 @@ export async function sharedSecretPubKey() { .then((pubkey) => p256.ProjectivePoint.fromHex(pubkey)); } -export async function sharedSecret(pubHex: string, salt: string, extraInfo?: string): Promise { - return await invoke("plugin:keystore|shared_secret", { +export async function sharedSecret(pubKeysHex: string[], salt: string, extraInfo?: string): Promise<{ chacha20Keys: string[] } | null> { + return await invoke<{ chacha20Keys: string[] }>("plugin:keystore|shared_secret", { payload: { - withP256PubKey: pubHex, + withP256PubKeys: pubKeysHex, extraInfo, salt }, diff --git a/package.json b/package.json index 3ec8cae..73cf657 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { - "name": "@impierce/tauri-plugin-keystore", + "name": "@0x330a/tauri-plugin-keystore", "version": "2.1.0-alpha.1", - "author": "daniel-mader", - "description": "Interact with the device-native key storage (Android Keystore, iOS Keychain).", + "author": "0x330a", + "description": "Interact with the device-native key storage (Android Keystore, iOS Keychain). perform ecdh operations for generating symmetric keys", "type": "module", "types": "./dist-js/index.d.ts", "main": "./dist-js/index.cjs", From 4a34fac8848fa5c28fe6c1dede3b83e87edf1934 Mon Sep 17 00:00:00 2001 From: 0x330a <92654767+0x330a@users.noreply.github.com> Date: Wed, 28 May 2025 23:35:15 +0700 Subject: [PATCH 09/33] feat: add contains key / plaintext --- android/src/main/java/KeystorePlugin.kt | 22 ++++++-- build.rs | 12 ++++- guest-js/index.ts | 28 ++++++++-- .../autogenerated/commands/contains_key.toml | 13 +++++ .../commands/contains_unencrypted_key.toml | 13 +++++ permissions/autogenerated/reference.md | 54 +++++++++++++++++++ permissions/default.toml | 2 +- permissions/schemas/schema.json | 28 +++++++++- src/commands.rs | 18 ++++++- src/lib.rs | 2 + src/mobile.rs | 12 +++++ 11 files changed, 190 insertions(+), 14 deletions(-) create mode 100644 permissions/autogenerated/commands/contains_key.toml create mode 100644 permissions/autogenerated/commands/contains_unencrypted_key.toml diff --git a/android/src/main/java/KeystorePlugin.kt b/android/src/main/java/KeystorePlugin.kt index 85a028f..c0ff0a3 100644 --- a/android/src/main/java/KeystorePlugin.kt +++ b/android/src/main/java/KeystorePlugin.kt @@ -76,6 +76,20 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { } } + @Command + fun contains_key(invoke: Invoke) { + val key = invoke.parseArgs(RetrieveRequest::class.java).key + val prefs = activity.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE) + invoke.resolveObject(prefs.contains("ciphertext-$key")) + } + + @Command + fun contains_unencrypted_key(invoke: Invoke) { + val key = invoke.parseArgs(RetrieveRequest::class.java).key + invoke.resolveObject(activity.getSharedPreferences(unencryptedStoreName, Context.MODE_PRIVATE).contains(key)) + } + + @Command fun store_unencrypted(invoke: Invoke) { val args = invoke.parseArgs(StoreRequest::class.java) @@ -376,12 +390,12 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { private fun storeCiphertext(key: String, iv: ByteArray, ciphertext: ByteArray) { val prefs: SharedPreferences = activity.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE) - val editor = prefs.edit() val ivEncoded = Base64.encodeToString(iv, Base64.DEFAULT) val ctEncoded = Base64.encodeToString(ciphertext, Base64.DEFAULT) - editor.putString("iv-$key", ivEncoded) - editor.putString("ciphertext-$key", ctEncoded) - editor.apply() + prefs.edit { + putString("iv-$key", ivEncoded) + putString("ciphertext-$key", ctEncoded) + } } @Command diff --git a/build.rs b/build.rs index d0e5017..273d50d 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,14 @@ -const COMMANDS: &[&str] = &["remove", "retrieve", "store", "shared_secret", "shared_secret_pub_key", "store_unencrypted", "retrieve_unencrypted"]; +const COMMANDS: &[&str] = &[ + "remove", + "retrieve", + "store", + "contains_unencrypted_key", + "contains_key", + "shared_secret", + "shared_secret_pub_key", + "store_unencrypted", + "retrieve_unencrypted", +]; fn main() { tauri_plugin::Builder::new(COMMANDS) diff --git a/guest-js/index.ts b/guest-js/index.ts index 5420e2a..0a94b4d 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -19,20 +19,36 @@ export async function store(key: string, value: string): Promise { }); } -export async function retrievePlaintext(key: string) : Promise { - return await invoke<{value ?: string }>("plugin:keystore|retrieve_unencrypted", { +export async function retrievePlaintext(key: string): Promise { + return await invoke<{ value?: string }>("plugin:keystore|retrieve_unencrypted", { payload: { key } }).then((r) => (r.value ? r.value : null)); } +export async function containsPlaintextKey(key: string): Promise { + return await invoke("plugin:keystore|contains_unencrypted_key", { + payload: { + key + } + }); +} + +export async function containsKey(key: string): Promise { + return await invoke("plugin:keystore|contains_key", { + payload: { + key + } + }).then() +} + export async function retrieve(key: string): Promise { - return await invoke<{ value?: string }>("plugin:keystore|retrieve", { + return await invoke<{ value?: string }>("plugin:keystore|retrieve", { payload: { key }, - }).then((r) => (r.value ? r.value : null)); + }).then((r) => r.value ? r.value : null); } export async function remove(service: string, user: string) { @@ -49,7 +65,9 @@ export async function sharedSecretPubKey() { .then((pubkey) => p256.ProjectivePoint.fromHex(pubkey)); } -export async function sharedSecret(pubKeysHex: string[], salt: string, extraInfo?: string): Promise<{ chacha20Keys: string[] } | null> { +export async function sharedSecret(pubKeysHex: string[], salt: string, extraInfo?: string): Promise<{ + chacha20Keys: string[] +} | null> { return await invoke<{ chacha20Keys: string[] }>("plugin:keystore|shared_secret", { payload: { withP256PubKeys: pubKeysHex, diff --git a/permissions/autogenerated/commands/contains_key.toml b/permissions/autogenerated/commands/contains_key.toml new file mode 100644 index 0000000..2b9cc93 --- /dev/null +++ b/permissions/autogenerated/commands/contains_key.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-contains-key" +description = "Enables the contains_key command without any pre-configured scope." +commands.allow = ["contains_key"] + +[[permission]] +identifier = "deny-contains-key" +description = "Denies the contains_key command without any pre-configured scope." +commands.deny = ["contains_key"] diff --git a/permissions/autogenerated/commands/contains_unencrypted_key.toml b/permissions/autogenerated/commands/contains_unencrypted_key.toml new file mode 100644 index 0000000..9bad10a --- /dev/null +++ b/permissions/autogenerated/commands/contains_unencrypted_key.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-contains-unencrypted-key" +description = "Enables the contains_unencrypted_key command without any pre-configured scope." +commands.allow = ["contains_unencrypted_key"] + +[[permission]] +identifier = "deny-contains-unencrypted-key" +description = "Denies the contains_unencrypted_key command without any pre-configured scope." +commands.deny = ["contains_unencrypted_key"] diff --git a/permissions/autogenerated/reference.md b/permissions/autogenerated/reference.md index 4a4a47e..8db6420 100644 --- a/permissions/autogenerated/reference.md +++ b/permissions/autogenerated/reference.md @@ -5,6 +5,8 @@ Default permissions for the plugin #### This default permission set includes the following: - `allow-remove` +- `allow-contains-key` +- `allow-contains-unencrypted-key` - `allow-retrieve` - `allow-store` - `allow-shared-secret` @@ -21,6 +23,58 @@ Default permissions for the plugin + + + +`keystore:allow-contains-key` + + + + +Enables the contains_key command without any pre-configured scope. + + + + + + + +`keystore:deny-contains-key` + + + + +Denies the contains_key command without any pre-configured scope. + + + + + + + +`keystore:allow-contains-unencrypted-key` + + + + +Enables the contains_unencrypted_key command without any pre-configured scope. + + + + + + + +`keystore:deny-contains-unencrypted-key` + + + + +Denies the contains_unencrypted_key command without any pre-configured scope. + + + + diff --git a/permissions/default.toml b/permissions/default.toml index c29ff3c..b790537 100644 --- a/permissions/default.toml +++ b/permissions/default.toml @@ -1,3 +1,3 @@ [default] description = "Default permissions for the plugin" -permissions = ["allow-remove", "allow-retrieve", "allow-store", "allow-shared-secret", "allow-shared-secret-pub-key", "allow-store-unencrypted", "allow-retrieve-unencrypted"] +permissions = ["allow-remove", "allow-contains-key", "allow-contains-unencrypted-key", "allow-retrieve", "allow-store", "allow-shared-secret", "allow-shared-secret-pub-key", "allow-store-unencrypted", "allow-retrieve-unencrypted"] diff --git a/permissions/schemas/schema.json b/permissions/schemas/schema.json index 8fd6a26..342bc2f 100644 --- a/permissions/schemas/schema.json +++ b/permissions/schemas/schema.json @@ -294,6 +294,30 @@ "PermissionKind": { "type": "string", "oneOf": [ + { + "description": "Enables the contains_key command without any pre-configured scope.", + "type": "string", + "const": "allow-contains-key", + "markdownDescription": "Enables the contains_key command without any pre-configured scope." + }, + { + "description": "Denies the contains_key command without any pre-configured scope.", + "type": "string", + "const": "deny-contains-key", + "markdownDescription": "Denies the contains_key command without any pre-configured scope." + }, + { + "description": "Enables the contains_unencrypted_key command without any pre-configured scope.", + "type": "string", + "const": "allow-contains-unencrypted-key", + "markdownDescription": "Enables the contains_unencrypted_key command without any pre-configured scope." + }, + { + "description": "Denies the contains_unencrypted_key command without any pre-configured scope.", + "type": "string", + "const": "deny-contains-unencrypted-key", + "markdownDescription": "Denies the contains_unencrypted_key command without any pre-configured scope." + }, { "description": "Enables the remove command without any pre-configured scope.", "type": "string", @@ -379,10 +403,10 @@ "markdownDescription": "Denies the store_unencrypted command without any pre-configured scope." }, { - "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-remove`\n- `allow-retrieve`\n- `allow-store`\n- `allow-shared-secret`\n- `allow-shared-secret-pub-key`\n- `allow-store-unencrypted`\n- `allow-retrieve-unencrypted`", + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-remove`\n- `allow-contains-key`\n- `allow-contains-unencrypted-key`\n- `allow-retrieve`\n- `allow-store`\n- `allow-shared-secret`\n- `allow-shared-secret-pub-key`\n- `allow-store-unencrypted`\n- `allow-retrieve-unencrypted`", "type": "string", "const": "default", - "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-remove`\n- `allow-retrieve`\n- `allow-store`\n- `allow-shared-secret`\n- `allow-shared-secret-pub-key`\n- `allow-store-unencrypted`\n- `allow-retrieve-unencrypted`" + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-remove`\n- `allow-contains-key`\n- `allow-contains-unencrypted-key`\n- `allow-retrieve`\n- `allow-store`\n- `allow-shared-secret`\n- `allow-shared-secret-pub-key`\n- `allow-store-unencrypted`\n- `allow-retrieve-unencrypted`" } ] } diff --git a/src/commands.rs b/src/commands.rs index 695df79..cbf1a89 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,7 +1,7 @@ use hex::ToHex; use hkdf::Hkdf; use sha2::{Digest, Sha512}; -use tauri::{command, AppHandle, Runtime}; +use tauri::{command, App, AppHandle, Runtime}; use crate::models::*; use crate::KeystoreExt; @@ -38,6 +38,22 @@ pub(crate) async fn retrieve( app.keystore().retrieve(payload) } +#[command] +pub(crate) async fn contains_key( + app: AppHandle, + payload: RetrieveRequest, +) -> crate::Result { + app.keystore().contains_key(payload) +} + +#[command] +pub(crate) async fn contains_unencrypted_key( + app: AppHandle, + payload: RetrieveRequest, +) -> crate::Result { + app.keystore().contains_unencrypted_key(payload) +} + #[command] pub(crate) async fn remove( app: AppHandle, diff --git a/src/lib.rs b/src/lib.rs index adab0b6..4a1a48b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,6 +41,8 @@ pub fn init() -> TauriPlugin { .invoke_handler(tauri::generate_handler![ commands::store_unencrypted, commands::retrieve_unencrypted, + commands::contains_key, + commands::contains_unencrypted_key, commands::remove, commands::retrieve, commands::store, diff --git a/src/mobile.rs b/src/mobile.rs index 1e485a5..b176383 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -50,6 +50,18 @@ impl Keystore { .map_err(Into::into) } + pub fn contains_key(&self, payload: RetrieveRequest) -> crate::Result { + self.0 + .run_mobile_plugin("contains_key", payload) + .map_err(Into::into) + } + + pub fn contains_unencrypted_key(&self, payload: RetrieveRequest) -> crate::Result { + self.0 + .run_mobile_plugin("contains_unencrypted_key", payload) + .map_err(Into::into) + } + pub fn remove(&self, payload: RemoveRequest) -> crate::Result<()> { self.0 .run_mobile_plugin("remove", payload) From c31055a061ef8bc5e661a9e8e963c14b38345fd9 Mon Sep 17 00:00:00 2001 From: 0x330a <92654767+0x330a@users.noreply.github.com> Date: Thu, 29 May 2025 11:36:07 +0700 Subject: [PATCH 10/33] refactor: package deps --- package.json | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 73cf657..3e327b6 100644 --- a/package.json +++ b/package.json @@ -34,10 +34,6 @@ "license": "Apache-2.0", "repository": { "type": "git", - "url": "git+https://github.com/impierce/tauri-plugin-keystore.git" - }, - "bugs": { - "url": "https://github.com/impierce/tauri-plugin-keystore/issues" - }, - "homepage": "https://github.com/impierce/tauri-plugin-keystore#readme" + "url": "git+https://github.com/0x330a-public/tauri-plugin-keystore.git" + } } From 581ef7795deca33e817e284a03bfa91e9635c424 Mon Sep 17 00:00:00 2001 From: 0x330a <92654767+0x330a@users.noreply.github.com> Date: Thu, 29 May 2025 11:37:45 +0700 Subject: [PATCH 11/33] refactor: match naming convention from p256 signer --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3e327b6..ea2dee0 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@0x330a/tauri-plugin-keystore", + "name": "@0x330a/tauri-plugin-keystore-api", "version": "2.1.0-alpha.1", "author": "0x330a", "description": "Interact with the device-native key storage (Android Keystore, iOS Keychain). perform ecdh operations for generating symmetric keys", From 3bb2c7e282804d2a6cd68508fa15d296ec1b5a52 Mon Sep 17 00:00:00 2001 From: 0x330a <92654767+0x330a@users.noreply.github.com> Date: Wed, 16 Jul 2025 14:59:20 +1000 Subject: [PATCH 12/33] fix: add key to readCipherData --- android/src/main/java/KeystorePlugin.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/KeystorePlugin.kt b/android/src/main/java/KeystorePlugin.kt index c0ff0a3..a82c468 100644 --- a/android/src/main/java/KeystorePlugin.kt +++ b/android/src/main/java/KeystorePlugin.kt @@ -402,7 +402,7 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { fun retrieve(invoke: Invoke) { val args = invoke.parseArgs(RetrieveRequest::class.java) - val cipherData = readCipherData() + val cipherData = readCipherData(args.key) if (cipherData == null) { invoke.resolve(JSObject("{value: null}")) return @@ -460,11 +460,11 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { } // Reads the IV and ciphertext from SharedPreferences. - private fun readCipherData(): Pair? { + private fun readCipherData(key: String): Pair? { val prefs: SharedPreferences = activity.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE) - val ivEncoded: String? = prefs.getString("iv", null) - val ctEncoded: String? = prefs.getString("ciphertext", null) + val ivEncoded: String? = prefs.getString("iv-$key", null) + val ctEncoded: String? = prefs.getString("ciphertext-$key", null) if (ivEncoded == null || ctEncoded == null) { return null } From a53c2e39d6f29dc80f26a5c2c319f194ec598ae2 Mon Sep 17 00:00:00 2001 From: 0x330a <92654767+0x330a@users.noreply.github.com> Date: Wed, 30 Jul 2025 13:28:05 +1000 Subject: [PATCH 13/33] feat: add hmac_sha256 function backed by android keystore --- android/src/main/java/KeystorePlugin.kt | 63 ++++++++++++++++++- build.rs | 1 + .../autogenerated/commands/hmac_sha256.toml | 13 ++++ permissions/autogenerated/reference.md | 27 ++++++++ permissions/default.toml | 2 +- permissions/schemas/schema.json | 16 ++++- src/commands.rs | 8 +++ src/lib.rs | 3 +- src/mobile.rs | 6 ++ src/models.rs | 14 +++++ 10 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 permissions/autogenerated/commands/hmac_sha256.toml diff --git a/android/src/main/java/KeystorePlugin.kt b/android/src/main/java/KeystorePlugin.kt index a82c468..4ef7cb3 100644 --- a/android/src/main/java/KeystorePlugin.kt +++ b/android/src/main/java/KeystorePlugin.kt @@ -29,6 +29,7 @@ import javax.crypto.spec.GCMParameterSpec private const val KEY_ALIAS = "key_alias" private const val KEY_AGREEMENT_ALIAS = "key_agreement_alias" +private const val KEY_HMAC_ALIAS = "key_hmac_alias" private const val ANDROID_KEYSTORE = "AndroidKeyStore" private const val SHARED_PREFERENCES_NAME = "secure_storage" @@ -63,6 +64,16 @@ data class SharedSecretResponse( val sharedSecrets: List ) +@InvokeArg +class Hmac256Request{ + lateinit var input: String +} + +data class Hmac256Response( + val output: String +) + + @TauriPlugin class KeystorePlugin(private val activity: Activity) : Plugin(activity) { @@ -180,6 +191,7 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) // Require authentication on every use: .setUserAuthenticationRequired(true) + .setInvalidatedByBiometricEnrollment(false) .setUserAuthenticationValidityDurationSeconds(-1) .build() keyGenerator.init(keyGenParameterSpec) @@ -241,10 +253,34 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { .build() keyGenerator.initialize(keyGenParameterSpec) + keyGenerator.generateKeyPair() } } + private fun ensureHmacKey() { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE) + keyStore.load(null) + + if (!keyStore.containsAlias(KEY_HMAC_ALIAS)) { + val keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_HMAC_SHA256, + ANDROID_KEYSTORE + ) + val parameterSpec = KeyGenParameterSpec.Builder( + KEY_HMAC_ALIAS, + KeyProperties.PURPOSE_SIGN + ) + // Require authentication on every use: + .setUserAuthenticationRequired(true) + .setInvalidatedByBiometricEnrollment(false) + .setUserAuthenticationValidityDurationSeconds(-1) + .build() + keyGenerator.init(parameterSpec) + keyGenerator.generateKey() + } + } + @Command fun shared_secret_pub_key(invoke: Invoke) { // ensure we have generated the key @@ -491,4 +527,29 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { invoke.reject("Could not delete entry from KeyStore: ${e.localizedMessage}") } } -} + + @Command + fun hmac_sha256(invoke: Invoke) { + try { + val request = invoke.parseArgs(Hmac256Request::class.java) + + ensureHmacKey() + + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE) + keyStore.load(null) + val key = keyStore.getKey(KEY_HMAC_ALIAS, null) + + val mac = Mac.getInstance("HmacSHA256") + mac.init(key) + val resultBytes = mac.doFinal(request.input.toByteArray()) + + val hexOutput = encode(resultBytes) + + val response = Hmac256Response(output = hexOutput) + invoke.resolveObject(response) + } catch (e: Exception) { + Logger.error("hmac_sha256", e.toString(), e) + invoke.reject("Failed to compute HMAC-SHA256: ${e.message}") + } + } +} \ No newline at end of file diff --git a/build.rs b/build.rs index 273d50d..99db55c 100644 --- a/build.rs +++ b/build.rs @@ -8,6 +8,7 @@ const COMMANDS: &[&str] = &[ "shared_secret_pub_key", "store_unencrypted", "retrieve_unencrypted", + "hmac_sha256" ]; fn main() { diff --git a/permissions/autogenerated/commands/hmac_sha256.toml b/permissions/autogenerated/commands/hmac_sha256.toml new file mode 100644 index 0000000..f816b46 --- /dev/null +++ b/permissions/autogenerated/commands/hmac_sha256.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-hmac-sha256" +description = "Enables the hmac_sha256 command without any pre-configured scope." +commands.allow = ["hmac_sha256"] + +[[permission]] +identifier = "deny-hmac-sha256" +description = "Denies the hmac_sha256 command without any pre-configured scope." +commands.deny = ["hmac_sha256"] diff --git a/permissions/autogenerated/reference.md b/permissions/autogenerated/reference.md index 8db6420..082b8c5 100644 --- a/permissions/autogenerated/reference.md +++ b/permissions/autogenerated/reference.md @@ -13,6 +13,7 @@ Default permissions for the plugin - `allow-shared-secret-pub-key` - `allow-store-unencrypted` - `allow-retrieve-unencrypted` +- `allow-hmac-sha256` ## Permission Table @@ -78,6 +79,32 @@ Denies the contains_unencrypted_key command without any pre-configured scope. +`keystore:allow-hmac-sha256` + + + + +Enables the hmac_sha256 command without any pre-configured scope. + + + + + + + +`keystore:deny-hmac-sha256` + + + + +Denies the hmac_sha256 command without any pre-configured scope. + + + + + + + `keystore:allow-remove` diff --git a/permissions/default.toml b/permissions/default.toml index b790537..5d69cb7 100644 --- a/permissions/default.toml +++ b/permissions/default.toml @@ -1,3 +1,3 @@ [default] description = "Default permissions for the plugin" -permissions = ["allow-remove", "allow-contains-key", "allow-contains-unencrypted-key", "allow-retrieve", "allow-store", "allow-shared-secret", "allow-shared-secret-pub-key", "allow-store-unencrypted", "allow-retrieve-unencrypted"] +permissions = ["allow-remove", "allow-contains-key", "allow-contains-unencrypted-key", "allow-retrieve", "allow-store", "allow-shared-secret", "allow-shared-secret-pub-key", "allow-store-unencrypted", "allow-retrieve-unencrypted", "allow-hmac-sha256"] diff --git a/permissions/schemas/schema.json b/permissions/schemas/schema.json index 342bc2f..8fdfeb2 100644 --- a/permissions/schemas/schema.json +++ b/permissions/schemas/schema.json @@ -318,6 +318,18 @@ "const": "deny-contains-unencrypted-key", "markdownDescription": "Denies the contains_unencrypted_key command without any pre-configured scope." }, + { + "description": "Enables the hmac_sha256 command without any pre-configured scope.", + "type": "string", + "const": "allow-hmac-sha256", + "markdownDescription": "Enables the hmac_sha256 command without any pre-configured scope." + }, + { + "description": "Denies the hmac_sha256 command without any pre-configured scope.", + "type": "string", + "const": "deny-hmac-sha256", + "markdownDescription": "Denies the hmac_sha256 command without any pre-configured scope." + }, { "description": "Enables the remove command without any pre-configured scope.", "type": "string", @@ -403,10 +415,10 @@ "markdownDescription": "Denies the store_unencrypted command without any pre-configured scope." }, { - "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-remove`\n- `allow-contains-key`\n- `allow-contains-unencrypted-key`\n- `allow-retrieve`\n- `allow-store`\n- `allow-shared-secret`\n- `allow-shared-secret-pub-key`\n- `allow-store-unencrypted`\n- `allow-retrieve-unencrypted`", + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-remove`\n- `allow-contains-key`\n- `allow-contains-unencrypted-key`\n- `allow-retrieve`\n- `allow-store`\n- `allow-shared-secret`\n- `allow-shared-secret-pub-key`\n- `allow-store-unencrypted`\n- `allow-retrieve-unencrypted`\n- `allow-hmac-sha256`", "type": "string", "const": "default", - "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-remove`\n- `allow-contains-key`\n- `allow-contains-unencrypted-key`\n- `allow-retrieve`\n- `allow-store`\n- `allow-shared-secret`\n- `allow-shared-secret-pub-key`\n- `allow-store-unencrypted`\n- `allow-retrieve-unencrypted`" + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-remove`\n- `allow-contains-key`\n- `allow-contains-unencrypted-key`\n- `allow-retrieve`\n- `allow-store`\n- `allow-shared-secret`\n- `allow-shared-secret-pub-key`\n- `allow-store-unencrypted`\n- `allow-retrieve-unencrypted`\n- `allow-hmac-sha256`" } ] } diff --git a/src/commands.rs b/src/commands.rs index cbf1a89..3df6795 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -106,3 +106,11 @@ pub(crate) async fn shared_secret ( chacha_20_keys, }) } + +#[command] +pub(crate) async fn hmac_sha256( + app: AppHandle, + payload: HmacSha256Request +) -> crate::Result { + app.keystore().hmac_sha256(payload) +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 4a1a48b..13f35e2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,7 +47,8 @@ pub fn init() -> TauriPlugin { commands::retrieve, commands::store, commands::shared_secret, - commands::shared_secret_pub_key + commands::shared_secret_pub_key, + commands::hmac_sha256 ]) .setup(|app, api: PluginApi| { let keystore = mobile::init(app, api)?; diff --git a/src/mobile.rs b/src/mobile.rs index b176383..73cb47a 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -79,5 +79,11 @@ impl Keystore { .run_mobile_plugin("shared_secret_pub_key", ()) .map_err(Into::into) } + + pub fn hmac_sha256(&self, payload: HmacSha256Request) -> crate::Result { + self.0 + .run_mobile_plugin("hmac_sha256", payload) + .map_err(Into::into) + } } diff --git a/src/models.rs b/src/models.rs index 4f5877e..d578888 100644 --- a/src/models.rs +++ b/src/models.rs @@ -71,3 +71,17 @@ pub struct ChaChaSharedSecret { /// List of ChaCha20 keys in hex format pub chacha_20_keys: Vec } + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HmacSha256Request { + /// Hex encoded input bytes + pub input: String +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HmacSha256Response { + /// Hex encoded bytes + pub output: String +} \ No newline at end of file From dbd7f5b10e21b97c1c010b2f63e5025abf4462de Mon Sep 17 00:00:00 2001 From: 0x330a <92654767+0x330a@users.noreply.github.com> Date: Wed, 30 Jul 2025 14:39:33 +1000 Subject: [PATCH 14/33] feat: add guest-js function for hmacSha256 --- guest-js/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/guest-js/index.ts b/guest-js/index.ts index 0a94b4d..73eed63 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -75,4 +75,12 @@ export async function sharedSecret(pubKeysHex: string[], salt: string, extraInfo salt }, }); +} + +export async function hmacSha256(input: string): Promise<{output: string} | null> { + return await invoke<{output: string}>("plugin:keystore|hmac_sha256", { + payload: { + input + } + }); } \ No newline at end of file From 576a02193bd45fb0439efb55f7ba20842ce8eedc Mon Sep 17 00:00:00 2001 From: 0x330a <92654767+0x330a@users.noreply.github.com> Date: Wed, 30 Jul 2025 14:39:52 +1000 Subject: [PATCH 15/33] chore: update build version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ea2dee0..38151c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@0x330a/tauri-plugin-keystore-api", - "version": "2.1.0-alpha.1", + "version": "2.1.0-alpha.2", "author": "0x330a", "description": "Interact with the device-native key storage (Android Keystore, iOS Keychain). perform ecdh operations for generating symmetric keys", "type": "module", From d3a454ef27cc5064c4dafb65395d257f983c9a34 Mon Sep 17 00:00:00 2001 From: 0x330a <92654767+0x330a@users.noreply.github.com> Date: Wed, 30 Jul 2025 14:40:52 +1000 Subject: [PATCH 16/33] chore: update cargo version --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 787b8d6..3eaa12a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tauri-plugin-keystore" -version = "2.1.0-alpha.1" +version = "2.1.0-alpha.2" authors = ["daniel-mader"] description = "Interact with the device-native key storage (Android Keystore, iOS Keychain)." edition = "2021" From 1e6c20bfab78225770b778afd1f90bc5d38d2f73 Mon Sep 17 00:00:00 2001 From: 0x330a <92654767+0x330a@users.noreply.github.com> Date: Fri, 1 Aug 2025 14:59:54 +1000 Subject: [PATCH 17/33] feat: try without biometric if running in debug build mode --- android/src/main/java/KeystorePlugin.kt | 231 +++++++++++++----------- 1 file changed, 127 insertions(+), 104 deletions(-) diff --git a/android/src/main/java/KeystorePlugin.kt b/android/src/main/java/KeystorePlugin.kt index 4ef7cb3..d3d6674 100644 --- a/android/src/main/java/KeystorePlugin.kt +++ b/android/src/main/java/KeystorePlugin.kt @@ -10,6 +10,7 @@ import android.webkit.WebView import androidx.biometric.BiometricPrompt import androidx.core.content.ContextCompat import androidx.core.content.edit +import app.tauri.BuildConfig import app.tauri.Logger import app.tauri.annotation.Command import app.tauri.annotation.InvokeArg @@ -65,7 +66,7 @@ data class SharedSecretResponse( ) @InvokeArg -class Hmac256Request{ +class Hmac256Request { lateinit var input: String } @@ -133,7 +134,8 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { // Create biometric prompt val executor = ContextCompat.getMainExecutor(activity) val biometricPrompt = - BiometricPrompt(activity as androidx.fragment.app.FragmentActivity, executor, + BiometricPrompt( + activity as androidx.fragment.app.FragmentActivity, executor, object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { super.onAuthenticationSucceeded(result) @@ -190,7 +192,7 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) // Require authentication on every use: - .setUserAuthenticationRequired(true) + .setUserAuthenticationRequired(!BuildConfig.DEBUG) .setInvalidatedByBiometricEnrollment(false) .setUserAuthenticationValidityDurationSeconds(-1) .build() @@ -230,8 +232,7 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { // This requires point decompression which is more complex // Consider using Bouncy Castle for this throw IllegalArgumentException("Compressed keys not supported in this simple implementation") - } - else { + } else { throw IllegalArgumentException("Invalid public key format") } } @@ -253,7 +254,7 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { .build() keyGenerator.initialize(keyGenParameterSpec) - + keyGenerator.generateKeyPair() } } @@ -272,7 +273,7 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { KeyProperties.PURPOSE_SIGN ) // Require authentication on every use: - .setUserAuthenticationRequired(true) + .setUserAuthenticationRequired(!BuildConfig.DEBUG) .setInvalidatedByBiometricEnrollment(false) .setUserAuthenticationValidityDurationSeconds(-1) .build() @@ -320,73 +321,83 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { val signature = getSignature() Logger.debug("initiated cryptoObject") - // Create biometric prompt - val executor = ContextCompat.getMainExecutor(activity) - val biometricPrompt = - BiometricPrompt(activity as androidx.fragment.app.FragmentActivity, executor, - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - super.onAuthenticationSucceeded(result) - try { - // Get the Signature from the authentication result. - val authSig = signature - - val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } - val certificate = keyStore.getCertificate(KEY_AGREEMENT_ALIAS) - - // generate a random message - val message = SecureRandom.getSeed(32) - // update and output the signature previously initialized for signing - authSig.update(message) - val outputSig = authSig.sign() - Logger.debug("signed message") - - // init in verify mode and check the signature matches the key agreement certificate - authSig.initVerify(certificate) - Logger.debug("initVerify success") - authSig.update(message) - authSig.verify(outputSig) - - - // generate the shared secrets from agreement - val agreement = getAgreement() - val sharedSecrets = mutableListOf() - - // Process each public key - for (pubKey in params.withP256PubKeys) { - agreement.init(keyStore.getKey(KEY_AGREEMENT_ALIAS, null)) - agreement.doPhase(getPublicKeyFromHex(pubKey), true) - val secret = agreement.generateSecret() - sharedSecrets.add(encode(secret, prefix = "")) - } + fun performSharedSecret() { + // Get the Signature from the authentication result. + val authSig = signature - invoke.resolveObject(SharedSecretResponse(sharedSecrets)) - } catch (e: Exception) { - invoke.reject("Shared secret failed: ${e.message}") + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + val certificate = keyStore.getCertificate(KEY_AGREEMENT_ALIAS) + + // generate a random message + val message = SecureRandom.getSeed(32) + // update and output the signature previously initialized for signing + authSig.update(message) + val outputSig = authSig.sign() + Logger.debug("signed message") + + // init in verify mode and check the signature matches the key agreement certificate + authSig.initVerify(certificate) + Logger.debug("initVerify success") + authSig.update(message) + authSig.verify(outputSig) + + + // generate the shared secrets from agreement + val agreement = getAgreement() + val sharedSecrets = mutableListOf() + + // Process each public key + for (pubKey in params.withP256PubKeys) { + agreement.init(keyStore.getKey(KEY_AGREEMENT_ALIAS, null)) + agreement.doPhase(getPublicKeyFromHex(pubKey), true) + val secret = agreement.generateSecret() + sharedSecrets.add(encode(secret, prefix = "")) + } + + invoke.resolveObject(SharedSecretResponse(sharedSecrets)) + } + + if (BuildConfig.DEBUG) { + // expect we don't need a biometric prompt + performSharedSecret() + } else { + // Create biometric prompt + val executor = ContextCompat.getMainExecutor(activity) + val biometricPrompt = + BiometricPrompt( + activity as androidx.fragment.app.FragmentActivity, executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + try { + performSharedSecret() + } catch (e: Exception) { + invoke.reject("Shared secret failed: ${e.message}") + } } - } - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - super.onAuthenticationError(errorCode, errString) - invoke.reject("Authentication error: $errorCode") - } + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + invoke.reject("Authentication error: $errorCode") + } - override fun onAuthenticationFailed() { - super.onAuthenticationFailed() - invoke.reject("Authentication failed") - } - }) + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + invoke.reject("Authentication failed") + } + }) - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle("Authenticate to Compute Shared Secret") - .setSubtitle("Biometric authentication is required") - .setNegativeButtonText("Cancel") - .build() + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle("Authenticate to Compute Shared Secret") + .setSubtitle("Biometric authentication is required") + .setNegativeButtonText("Cancel") + .build() - try { - biometricPrompt.authenticate(promptInfo) - } catch (e: Error) { - Logger.error("couldn't start biometric?: ${e.message}") + try { + biometricPrompt.authenticate(promptInfo) + } catch (e: Error) { + Logger.error("couldn't start biometric?: ${e.message}") + } } } @@ -417,7 +428,7 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } val secretKey = keyStore.getKey(KEY_ALIAS, null) - val cipher = Cipher.getInstance( "AES/GCM/NoPadding") + val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init(Cipher.ENCRYPT_MODE, secretKey) return cipher } @@ -453,46 +464,58 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { return } - val executor = ContextCompat.getMainExecutor(activity) - val biometricPrompt = BiometricPrompt(activity as androidx.fragment.app.FragmentActivity, executor, - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - super.onAuthenticationSucceeded(result) - try { - // Use the cipher from the authentication result (which is now unlocked). - val authCipher = result.cryptoObject?.cipher - ?: throw IllegalStateException("Cipher not available after authentication") - val decryptedBytes = authCipher.doFinal(ciphertext) - val cleartext = String(decryptedBytes) - - val ret = JSObject() - ret.put("value", cleartext) - invoke.resolve(ret) - } catch (e: Exception) { - invoke.reject("Decryption failed: $e") + // Assume cipher is initialized and unlocked if biometric + fun performDecrypt(cipher: Cipher) { + // Use the cipher from the authentication result (which is now unlocked). + val decryptedBytes = cipher.doFinal(ciphertext) + val cleartext = String(decryptedBytes) + + val ret = JSObject() + ret.put("value", cleartext) + invoke.resolve(ret) + } + + + if (BuildConfig.DEBUG) { + performDecrypt(cipher) + } else { + val executor = ContextCompat.getMainExecutor(activity) + val biometricPrompt = BiometricPrompt( + activity as androidx.fragment.app.FragmentActivity, executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + try { + // Use the cipher from the authentication result (which is now unlocked). + val authCipher = result.cryptoObject?.cipher + ?: throw IllegalStateException("Cipher not available after authentication") + performDecrypt(authCipher) + } catch (e: Exception) { + invoke.reject("Decryption failed: $e") + } } - } - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - super.onAuthenticationError(errorCode, errString) - invoke.reject("Authentication error: $errorCode") - } + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + invoke.reject("Authentication error: $errorCode") + } - override fun onAuthenticationFailed() { - super.onAuthenticationFailed() - invoke.reject("Authentication failed") - } - }) + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + invoke.reject("Authentication failed") + } + }) - // Build the prompt info. - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle("Biometric Authentication") - .setSubtitle("Authenticate to decrypt your secret") - .setNegativeButtonText("Cancel") - .build() + // Build the prompt info. + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle("Biometric Authentication") + .setSubtitle("Authenticate to decrypt your secret") + .setNegativeButtonText("Cancel") + .build() - // Launch the biometric prompt. - biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher)) + // Launch the biometric prompt. + biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher)) + } } // Reads the IV and ciphertext from SharedPreferences. From 550d2935d8459f1873e71c07339602d8e317dfba Mon Sep 17 00:00:00 2001 From: 0x330a <92654767+0x330a@users.noreply.github.com> Date: Fri, 1 Aug 2025 15:02:22 +1000 Subject: [PATCH 18/33] feat: build new versions and align cargo and package json --- Cargo.toml | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3eaa12a..a8019d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tauri-plugin-keystore" -version = "2.1.0-alpha.2" +version = "2.1.0-alpha.3" authors = ["daniel-mader"] description = "Interact with the device-native key storage (Android Keystore, iOS Keychain)." edition = "2021" diff --git a/package.json b/package.json index 38151c3..5788a69 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@0x330a/tauri-plugin-keystore-api", - "version": "2.1.0-alpha.2", + "version": "2.1.0-alpha.3", "author": "0x330a", "description": "Interact with the device-native key storage (Android Keystore, iOS Keychain). perform ecdh operations for generating symmetric keys", "type": "module", From defa57c034e5a8c0fc235835a98ea0e092e1a22b Mon Sep 17 00:00:00 2001 From: 0x330a <92654767+0x330a@users.noreply.github.com> Date: Fri, 1 Aug 2025 15:32:51 +1000 Subject: [PATCH 19/33] refactor: encrypt should also perform biometric only in release mode --- Cargo.toml | 2 +- android/src/main/java/KeystorePlugin.kt | 94 ++++++++++++++----------- package.json | 2 +- 3 files changed, 53 insertions(+), 45 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a8019d1..6d12f7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tauri-plugin-keystore" -version = "2.1.0-alpha.3" +version = "2.1.0-alpha.4" authors = ["daniel-mader"] description = "Interact with the device-native key storage (Android Keystore, iOS Keychain)." edition = "2021" diff --git a/android/src/main/java/KeystorePlugin.kt b/android/src/main/java/KeystorePlugin.kt index d3d6674..c839e93 100644 --- a/android/src/main/java/KeystorePlugin.kt +++ b/android/src/main/java/KeystorePlugin.kt @@ -128,55 +128,63 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { // Get cipher for encryption val cipher = getEncryptionCipher() - // Wrap the Cipher in a CryptoObject. - val cryptoObject = BiometricPrompt.CryptoObject(cipher) + fun performEncrypt(cipher: Cipher) { + // Encrypt the value. + val ciphertext = + cipher.doFinal(storeRequest.value.toByteArray()) + val iv = cipher.iv // Capture the initialization vector. + // Store the ciphertext and IV. + storeCiphertext(storeRequest.key, iv, ciphertext) + Logger.info("Secret stored securely") + } - // Create biometric prompt - val executor = ContextCompat.getMainExecutor(activity) - val biometricPrompt = - BiometricPrompt( - activity as androidx.fragment.app.FragmentActivity, executor, - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - super.onAuthenticationSucceeded(result) - try { - // Get the cipher from the authentication result. - val authCipher = result.cryptoObject?.cipher - ?: throw IllegalStateException("Cipher not available after auth") - - // Encrypt the value. - val ciphertext = - authCipher.doFinal(storeRequest.value.toByteArray()) - val iv = authCipher.iv // Capture the initialization vector. - // Store the ciphertext and IV. - storeCiphertext(storeRequest.key, iv, ciphertext) - Logger.info("Secret stored securely") - } catch (e: Exception) { - e.printStackTrace() - Logger.error("Encryption failed: ${e.message}") - } - } + if (BuildConfig.DEBUG) { + // don't need to biometric unlock + performEncrypt(cipher) + } else { + // Wrap the Cipher in a CryptoObject. + val cryptoObject = BiometricPrompt.CryptoObject(cipher) - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - super.onAuthenticationError(errorCode, errString) - invoke.reject("Authentication error: $errorCode") - } + // Create biometric prompt + val executor = ContextCompat.getMainExecutor(activity) + val biometricPrompt = + BiometricPrompt( + activity as androidx.fragment.app.FragmentActivity, executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + try { + // Get the cipher from the authentication result. + val authCipher = result.cryptoObject?.cipher + ?: throw IllegalStateException("Cipher not available after auth") - override fun onAuthenticationFailed() { - super.onAuthenticationFailed() - invoke.reject("Authentication failed") - } - }) + performEncrypt(authCipher) + } catch (e: Exception) { + e.printStackTrace() + Logger.error("Encryption failed: ${e.message}") + } + invoke.resolve() + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + invoke.reject("Authentication error: $errorCode") + } - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle("Authenticate to Store Secret") - .setSubtitle("Biometric authentication is required") - .setNegativeButtonText("Cancel") - .build() + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + invoke.reject("Authentication failed") + } + }) - biometricPrompt.authenticate(promptInfo, cryptoObject) + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle("Authenticate to Store Secret") + .setSubtitle("Biometric authentication is required") + .setNegativeButtonText("Cancel") + .build() - invoke.resolve() + biometricPrompt.authenticate(promptInfo, cryptoObject) + } } // Generate key, if it doesn't exist. diff --git a/package.json b/package.json index 5788a69..fa3a204 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@0x330a/tauri-plugin-keystore-api", - "version": "2.1.0-alpha.3", + "version": "2.1.0-alpha.4", "author": "0x330a", "description": "Interact with the device-native key storage (Android Keystore, iOS Keychain). perform ecdh operations for generating symmetric keys", "type": "module", From bbd9f31ce08161f798fb9279a4a91311c44c45bb Mon Sep 17 00:00:00 2001 From: 0x330a <92654767+0x330a@users.noreply.github.com> Date: Fri, 1 Aug 2025 15:40:52 +1000 Subject: [PATCH 20/33] refactor: resolve correctly --- Cargo.toml | 2 +- android/src/main/java/KeystorePlugin.kt | 1 + package.json | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6d12f7f..98a4dcc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tauri-plugin-keystore" -version = "2.1.0-alpha.4" +version = "2.1.0-alpha.5" authors = ["daniel-mader"] description = "Interact with the device-native key storage (Android Keystore, iOS Keychain)." edition = "2021" diff --git a/android/src/main/java/KeystorePlugin.kt b/android/src/main/java/KeystorePlugin.kt index c839e93..2228e7b 100644 --- a/android/src/main/java/KeystorePlugin.kt +++ b/android/src/main/java/KeystorePlugin.kt @@ -141,6 +141,7 @@ class KeystorePlugin(private val activity: Activity) : Plugin(activity) { if (BuildConfig.DEBUG) { // don't need to biometric unlock performEncrypt(cipher) + invoke.resolve() } else { // Wrap the Cipher in a CryptoObject. val cryptoObject = BiometricPrompt.CryptoObject(cipher) diff --git a/package.json b/package.json index fa3a204..325c9b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@0x330a/tauri-plugin-keystore-api", - "version": "2.1.0-alpha.4", + "version": "2.1.0-alpha.5", "author": "0x330a", "description": "Interact with the device-native key storage (Android Keystore, iOS Keychain). perform ecdh operations for generating symmetric keys", "type": "module", From b4ebb86093fa30a5e90c7e17f5981e5bb83bcfa2 Mon Sep 17 00:00:00 2001 From: Paul Salisbury Date: Thu, 21 Aug 2025 14:43:58 +1200 Subject: [PATCH 21/33] wip: functional equivalence on iOS17+ --- ios/Package.swift | 33 ++- ios/Sources/KeystorePlugin.swift | 105 --------- ios/Sources/KeystorePlugin/Hex.swift | 28 +++ .../KeystorePlugin/KeychainHelper.swift | 122 +++++++++++ ios/Sources/KeystorePlugin/KeystoreCore.swift | 203 ++++++++++++++++++ ios/Sources/KeystorePlugin/PluginShim.swift | 69 ++++++ .../KeystorePluginTests.swift | 20 ++ ios/Tests/PluginTests/PluginTests.swift | 8 - 8 files changed, 456 insertions(+), 132 deletions(-) delete mode 100644 ios/Sources/KeystorePlugin.swift create mode 100644 ios/Sources/KeystorePlugin/Hex.swift create mode 100644 ios/Sources/KeystorePlugin/KeychainHelper.swift create mode 100644 ios/Sources/KeystorePlugin/KeystoreCore.swift create mode 100644 ios/Sources/KeystorePlugin/PluginShim.swift create mode 100644 ios/Tests/KeystorePluginTests/KeystorePluginTests.swift delete mode 100644 ios/Tests/PluginTests/PluginTests.swift diff --git a/ios/Package.swift b/ios/Package.swift index 217b62e..d2ee6a5 100644 --- a/ios/Package.swift +++ b/ios/Package.swift @@ -1,32 +1,27 @@ -// swift-tools-version:5.3 -// The swift-tools-version declares the minimum version of Swift required to build this package. +// swift-tools-version: 5.10 import PackageDescription let package = Package( - name: "tauri-plugin-keystore", + name: "KeystorePlugin", platforms: [ - .macOS(.v10_13), - .iOS(.v13), + .iOS(.v17) ], products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. .library( - name: "tauri-plugin-keystore", - type: .static, - targets: ["tauri-plugin-keystore"]), - ], - dependencies: [ - .package(name: "Tauri", path: "../.tauri/tauri-api") + name: "KeystorePlugin", + targets: ["KeystorePlugin"] + ) ], targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( - name: "tauri-plugin-keystore", - dependencies: [ - .byName(name: "Tauri") - ], - path: "Sources") + name: "KeystorePlugin", + path: "Sources/KeystorePlugin" + ), + .testTarget( + name: "KeystorePluginTests", + dependencies: ["KeystorePlugin"], + path: "Tests/KeystorePluginTests" + ) ] ) diff --git a/ios/Sources/KeystorePlugin.swift b/ios/Sources/KeystorePlugin.swift deleted file mode 100644 index 1150920..0000000 --- a/ios/Sources/KeystorePlugin.swift +++ /dev/null @@ -1,105 +0,0 @@ -import SwiftRs -import Tauri -import UIKit -import WebKit - -import LocalAuthentication - -class StoreRequest: Decodable { - let value: String -} - -class KeystorePlugin: Plugin { - @objc public func store(_ invoke: Invoke) throws { - let args = try invoke.parseArgs(StoreRequest.self) - - guard let secretData = args.value.data(using: .utf8) else { - throw NSError(domain: "StoreErrorDomain", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid secret string"]) - } - - // Create an access control object that requires user presence (biometrics or device passcode) - // and makes the item accessible only when the device is unlocked. - var error: Unmanaged? - guard let accessControl = SecAccessControlCreateWithFlags( - nil, - kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - .userPresence, - &error - ) else { - throw error!.takeRetainedValue() as Error - } - - // Build the keychain query. The account attribute here is used as the key to store/retrieve the secret. - let account = "com.impierce.identity-wallet.unime-dev" - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: account, - kSecAttrAccessControl as String: accessControl, - kSecValueData as String: secretData - ] - - // Delete any existing item with this account. - SecItemDelete(query as CFDictionary) - - // Add the new item to the keychain. - let status = SecItemAdd(query as CFDictionary, nil) - guard status == errSecSuccess else { - // throw KeychainError(status: status) - throw NSError(domain: NSOSStatusErrorDomain, code: Int(status), userInfo: nil) - } - - invoke.resolve() - } - - @objc public func retrieve(_ invoke: Invoke) throws { - let account = "com.impierce.identity-wallet.unime-dev" - let context = LAContext() - context.localizedReason = "Access your UniMe password" - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: account, - kSecReturnData as String: true, - kSecUseAuthenticationContext as String: context, - // kSecUseOperationPrompt as String: "Authenticate to retrieve your secret" - ] - - var item: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &item) - - // Check the result of the query. - guard status == errSecSuccess else { - throw NSError(domain: NSOSStatusErrorDomain, code: Int(status), userInfo: nil) - } - - // Convert the returned data into a String. - guard let data = item as? Data, - let secret = String(data: data, encoding: .utf8) else { - throw NSError(domain: "com.impierce.identity-wallet", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unable to decode secret"]) - } - - invoke.resolve(["value": secret]) - } - - @objc public func remove(_ invoke: Invoke) throws { - let account = "com.impierce.identity-wallet.unime-dev" - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: account - ] - - let status = SecItemDelete(query as CFDictionary) - - guard status == errSecSuccess || status == errSecItemNotFound else { - throw NSError(domain: NSOSStatusErrorDomain, code: Int(status), userInfo: nil) - } - - invoke.resolve() - } -} - -@_cdecl("init_plugin_keystore") -func initPlugin() -> Plugin { - return KeystorePlugin() -} diff --git a/ios/Sources/KeystorePlugin/Hex.swift b/ios/Sources/KeystorePlugin/Hex.swift new file mode 100644 index 0000000..e2d2ddc --- /dev/null +++ b/ios/Sources/KeystorePlugin/Hex.swift @@ -0,0 +1,28 @@ + +import Foundation + +@inline(__always) +func dataToHex(_ data: Data) -> String { + return data.map { String(format: "%02x", $0) }.joined() +} + +@inline(__always) +func hexToData(_ hex: String) -> Data? { + var hex = hex + if hex.hasPrefix("0x") { hex = String(hex.dropFirst(2)) } + let len = hex.count + if len % 2 != 0 { return nil } + var data = Data(capacity: len/2) + var idx = hex.startIndex + for _ in 0..<(len/2) { + let nextIndex = hex.index(idx, offsetBy: 2) + let byteString = hex[idx.. Data { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + kSecAttrSynchronizable as String: kCFBooleanFalse as Any + ] + if let ctx = context { + query[kSecUseAuthenticationContext as String] = ctx + } + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status != errSecItemNotFound else { throw KeychainError.itemNotFound } + guard status == errSecSuccess else { throw KeychainError.unhandledOSStatus(status) } + guard let data = item as? Data else { throw KeychainError.typeMismatch } + return data + } + + static func deleteGenericPassword(account: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrSynchronizable as String: kCFBooleanFalse as Any + ] + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess else { + throw KeychainError.unhandledOSStatus(status) + } + } + + // MARK: - Secure Enclave P-256 Private Key + + static func createOrLoadSecureEnclavePrivateKey(tag: Data, access: SecAccessControl) throws -> SecKey { + // Try load existing + if let existing = try? loadPrivateKey(tag: tag, context: nil) { + return existing + } + let attributes: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeySizeInBits as String: 256, + kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave, + kSecPrivateKeyAttrs as String: [ + kSecAttrIsPermanent as String: true, + kSecAttrApplicationTag as String: tag, + kSecAttrAccessControl as String: access + ] + ] + var error: Unmanaged? + guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else { + throw error!.takeRetainedValue() as Error + } + return privateKey + } + + static func loadPrivateKey(tag: Data, context: LAContext?) throws -> SecKey { + var query: [String: Any] = [ + kSecClass as String: kSecClassKey, + kSecAttrApplicationTag as String: tag, + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecReturnRef as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + if let ctx = context { + query[kSecUseAuthenticationContext as String] = ctx + } + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status != errSecItemNotFound else { throw KeychainError.itemNotFound } + guard status == errSecSuccess else { throw KeychainError.unhandledOSStatus(status) } + return item as! SecKey + } + + static func publicKey(for privateKey: SecKey) throws -> SecKey { + guard let pub = SecKeyCopyPublicKey(privateKey) else { + throw KeychainError.unhandledOSStatus(errSecInvalidKeyRef) + } + return pub + } + + static func publicKeyX963Data(for publicKey: SecKey) throws -> Data { + var error: Unmanaged? + guard let data = SecKeyCopyExternalRepresentation(publicKey, &error) as Data? else { + throw error!.takeRetainedValue() as Error + } + return data + } +} diff --git a/ios/Sources/KeystorePlugin/KeystoreCore.swift b/ios/Sources/KeystorePlugin/KeystoreCore.swift new file mode 100644 index 0000000..462c7e9 --- /dev/null +++ b/ios/Sources/KeystorePlugin/KeystoreCore.swift @@ -0,0 +1,203 @@ + +import Foundation +import CryptoKit +import Security +import LocalAuthentication + +public struct KeystoreResult: Encodable { + public let ok: Bool + public let result: T? + public let error: String? + public init(ok: Bool, result: T? = nil, error: String? = nil) { + self.ok = ok + self.result = result + self.error = error + } +} + +public final class KeystoreCore { + public static let shared = KeystoreCore() + + private let securePrefs = UserDefaults(suiteName: "secure_storage")! + private let plainPrefs = UserDefaults(suiteName: "unencrypted_store")! + + // Key identifiers + private let symEncAccount = "sym.enc" + private let symHmacAccount = "sym.hmac" + private let ecdhTag = "se.ecdh.private".data(using: .utf8)! + + private init() {} + + // MARK: - Public API (mirrors Android) + + // contains_key(key: String) + public func contains_key(_ key: String) -> KeystoreResult { + let hasIv = securePrefs.string(forKey: "iv-\(key)") != nil + let hasCt = securePrefs.string(forKey: "ciphertext-\(key)") != nil + return KeystoreResult(ok: true, result: hasIv && hasCt) + } + + // contains_unencrypted_key(key: String) + public func contains_unencrypted_key(_ key: String) -> KeystoreResult { + let exists = plainPrefs.object(forKey: key) != nil + return KeystoreResult(ok: true, result: exists) + } + + // store_unencrypted(key: String, value: String) + public func store_unencrypted(_ key: String, value: String) -> KeystoreResult { + plainPrefs.setValue(value, forKey: key) + return KeystoreResult(ok: true, result: true) + } + + // retrieve_unencrypted(key: String) + public func retrieve_unencrypted(_ key: String) -> KeystoreResult { + guard let v = plainPrefs.string(forKey: key) else { + return KeystoreResult(ok: false, result: nil, error: "not_found") + } + return KeystoreResult(ok: true, result: v) + } + + // remove(key: String) + public func remove(_ key: String) -> KeystoreResult { + securePrefs.removeObject(forKey: "iv-\(key)") + securePrefs.removeObject(forKey: "ciphertext-\(key)") + plainPrefs.removeObject(forKey: key) + return KeystoreResult(ok: true, result: true) + } + + // store(key: String, plaintext: String) + public func store(_ key: String, plaintext: String) -> KeystoreResult { + do { + let ctx = LAContext() + ctx.localizedReason = "Unlock to access encryption key" + let encKey = try loadOrCreateSymmetricKey(account: symEncAccount, context: ctx) + let nonce = AES.GCM.Nonce() + let sealed = try AES.GCM.seal(Data(plaintext.utf8), using: encKey, nonce: nonce) + let ivB64 = Data(nonce.withUnsafeBytes { Data($0) }).base64EncodedString() + let ct = sealed.ciphertext + sealed.tag + let ctB64 = ct.base64EncodedString() + securePrefs.setValue(ivB64, forKey: "iv-\(key)") + securePrefs.setValue(ctB64, forKey: "ciphertext-\(key)") + return KeystoreResult(ok: true, result: true) + } catch { + return KeystoreResult(ok: false, result: nil, error: String(describing: error)) + } + } + + // retrieve(key: String) + public func retrieve(_ key: String) -> KeystoreResult { + do { + guard let ivB64 = securePrefs.string(forKey: "iv-\(key)"), + let ctB64 = securePrefs.string(forKey: "ciphertext-\(key)"), + let iv = Data(base64Encoded: ivB64), + let ct = Data(base64Encoded: ctB64) else { + return KeystoreResult(ok: false, result: nil, error: "not_found") + } + let ctx = LAContext() + ctx.localizedReason = "Unlock to access encryption key" + let encKey = try loadOrCreateSymmetricKey(account: symEncAccount, context: ctx) + guard iv.count == 12 else { return KeystoreResult(ok: false, result: nil, error: "bad_iv_length") } + let nonce = try AES.GCM.Nonce(data: iv) + let ctOnly = ct.prefix(ct.count - 16) + let tag = ct.suffix(16) + let sealed = try AES.GCM.SealedBox(nonce: nonce, ciphertext: ctOnly, tag: tag) + let plaintext = try AES.GCM.open(sealed, using: encKey) + return KeystoreResult(ok: true, result: String(data: plaintext, encoding: .utf8) ?? "") + } catch { + return KeystoreResult(ok: false, result: nil, error: String(describing: error)) + } + } + + // hmac_sha256(message: String) -> hex + public func hmac_sha256(_ message: String) -> KeystoreResult { + do { + let ctx = LAContext() + ctx.localizedReason = "Unlock to access HMAC key" + let key = try loadOrCreateSymmetricKey(account: symHmacAccount, context: ctx) + let tag = HMAC.authenticationCode(for: Data(message.utf8), using: key) + let hex = tag.map { String(format: "%02x", $0) }.joined() + return KeystoreResult(ok: true, result: hex) + } catch { + return KeystoreResult(ok: false, result: nil, error: String(describing: error)) + } + } + + // shared_secret_pub_key() -> hex (uncompressed 04||X||Y) + public func shared_secret_pub_key() -> KeystoreResult { + do { + let ctx = LAContext() + ctx.localizedReason = "Unlock to access ECDH key" + let priv = try loadOrCreateECPrivateKey(context: ctx) + let pub = try KeychainHelper.publicKey(for: priv) + let pubData = try KeychainHelper.publicKeyX963Data(for: pub) + return KeystoreResult(ok: true, result: dataToHex(pubData)) + } catch { + return KeystoreResult(ok: false, result: nil, error: String(describing: error)) + } + } + + // shared_secret(pubKeys: [hex]) -> [hex] + public func shared_secret(_ pubKeys: [String]) -> KeystoreResult<[String]> { + do { + let ctx = LAContext() + ctx.localizedReason = "Unlock to perform key agreement" + let priv = try loadOrCreateECPrivateKey(context: ctx) + var results: [String] = [] + for hex in pubKeys { + guard let peerX963 = hexToData(hex) else { + return KeystoreResult(ok: false, result: nil, error: "bad_pubkey_hex") + } + let attrs: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeyClass as String: kSecAttrKeyClassPublic, + kSecAttrKeySizeInBits as String: 256 + ] + var err: Unmanaged? + guard let peerKey = SecKeyCreateWithData(peerX963 as CFData, attrs as CFDictionary, &err) else { + throw err!.takeRetainedValue() as Error + } + let params: [String: Any] = [:] // no KDF + var error: Unmanaged? + guard let secret = SecKeyCopyKeyExchangeResult(priv, SecKeyAlgorithm.ecdhKeyExchangeStandard, peerKey, params as CFDictionary, &error) as Data? else { + throw error!.takeRetainedValue() as Error + } + results.append(dataToHex(secret)) + } + return KeystoreResult(ok: true, result: results) + } catch { + return KeystoreResult(ok: false, result: nil, error: String(describing: error)) + } + } + + // MARK: - Internals + + private func loadOrCreateSymmetricKey(account: String, context: LAContext?) throws -> SymmetricKey { + // Try load + if let data = try? KeychainHelper.retrieveGenericPassword(account: account, context: context) { + return SymmetricKey(data: data) + } + // Create + let key = SymmetricKey(size: .bits256) + let data = key.withUnsafeBytes { Data($0) } + let access = try makeAccessControl(requirePrivateKeyUsage: false) + try KeychainHelper.saveGenericPassword(account: account, data: data, access: access) + return key + } + + private func loadOrCreateECPrivateKey(context: LAContext?) throws -> SecKey { + let access = try makeAccessControl(requirePrivateKeyUsage: true) + return try KeychainHelper.createOrLoadSecureEnclavePrivateKey(tag: ecdhTag, access: access) + } + + private func makeAccessControl(requirePrivateKeyUsage: Bool) throws -> SecAccessControl { + var flags: SecAccessControlCreateFlags = [.biometryCurrentSet, .userPresence] + if requirePrivateKeyUsage { + flags.insert(.privateKeyUsage) + } + var error: Unmanaged? + guard let ac = SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, flags, &error) else { + throw error!.takeRetainedValue() as Error + } + return ac + } +} diff --git a/ios/Sources/KeystorePlugin/PluginShim.swift b/ios/Sources/KeystorePlugin/PluginShim.swift new file mode 100644 index 0000000..46c4415 --- /dev/null +++ b/ios/Sources/KeystorePlugin/PluginShim.swift @@ -0,0 +1,69 @@ + +import Foundation + +#if canImport(Tauri) +import Tauri + +@objc(KeystorePlugin) +public class KeystorePlugin: Plugin { + private let core = KeystoreCore.shared + + @objc public func containsKey(_ invoke: Invoke) throws { + struct Args: Decodable { let key: String } + let args: Args = try invoke.parseArgs() + invoke.resolve(core.contains_key(args.key)) + } + + @objc public func containsUnencryptedKey(_ invoke: Invoke) throws { + struct Args: Decodable { let key: String } + let args: Args = try invoke.parseArgs() + invoke.resolve(core.contains_unencrypted_key(args.key)) + } + + @objc public func storeUnencrypted(_ invoke: Invoke) throws { + struct Args: Decodable { let key: String; let value: String } + let args: Args = try invoke.parseArgs() + invoke.resolve(core.store_unencrypted(args.key, value: args.value)) + } + + @objc public func retrieveUnencrypted(_ invoke: Invoke) throws { + struct Args: Decodable { let key: String } + let args: Args = try invoke.parseArgs() + invoke.resolve(core.retrieve_unencrypted(args.key)) + } + + @objc public func remove(_ invoke: Invoke) throws { + struct Args: Decodable { let key: String } + let args: Args = try invoke.parseArgs() + invoke.resolve(core.remove(args.key)) + } + + @objc public func store(_ invoke: Invoke) throws { + struct Args: Decodable { let key: String; let plaintext: String } + let args: Args = try invoke.parseArgs() + invoke.resolve(core.store(args.key, plaintext: args.plaintext)) + } + + @objc public func retrieve(_ invoke: Invoke) throws { + struct Args: Decodable { let key: String } + let args: Args = try invoke.parseArgs() + invoke.resolve(core.retrieve(args.key)) + } + + @objc public func hmacSha256(_ invoke: Invoke) throws { + struct Args: Decodable { let message: String } + let args: Args = try invoke.parseArgs() + invoke.resolve(core.hmac_sha256(args.message)) + } + + @objc public func sharedSecretPubKey(_ invoke: Invoke) throws { + invoke.resolve(core.shared_secret_pub_key()) + } + + @objc public func sharedSecret(_ invoke: Invoke) throws { + struct Args: Decodable { let pubKeys: [String] } + let args: Args = try invoke.parseArgs() + invoke.resolve(core.shared_secret(args.pubKeys)) + } +} +#endif diff --git a/ios/Tests/KeystorePluginTests/KeystorePluginTests.swift b/ios/Tests/KeystorePluginTests/KeystorePluginTests.swift new file mode 100644 index 0000000..505bae6 --- /dev/null +++ b/ios/Tests/KeystorePluginTests/KeystorePluginTests.swift @@ -0,0 +1,20 @@ + +import XCTest +@testable import KeystorePlugin + +final class KeystorePluginTests: XCTestCase { + func testHexRoundtrip() throws { + let bytes = Data((0..<32).map { _ in UInt8.random(in: 0...255) }) + let hex = dataToHex(bytes) + let back = hexToData(hex) + XCTAssertEqual(bytes, back) + } + + func testPlainStoreRetrieve() throws { + let core = KeystoreCore.shared + _ = core.store_unencrypted("hello", value: "world") + let res = core.retrieve_unencrypted("hello") + XCTAssertTrue(res.ok) + XCTAssertEqual(res.result, "world") + } +} diff --git a/ios/Tests/PluginTests/PluginTests.swift b/ios/Tests/PluginTests/PluginTests.swift deleted file mode 100644 index e08b0fc..0000000 --- a/ios/Tests/PluginTests/PluginTests.swift +++ /dev/null @@ -1,8 +0,0 @@ -import XCTest -@testable import KeystorePlugin - -final class KeystorePluginTests: XCTestCase { - func testExample() throws { - let plugin = KeystorePlugin() - } -} From f784c6f43fa2f3fb0b91e04d1eee2a783585a9df Mon Sep 17 00:00:00 2001 From: Paul Salisbury Date: Thu, 21 Aug 2025 21:20:09 +1200 Subject: [PATCH 22/33] chore: SwiftRs and C declaration in shim --- ios/Package.swift | 19 +---- .../KeystorePlugin/KeychainHelper.swift | 6 +- ios/Sources/KeystorePlugin/KeystoreCore.swift | 85 +++++++------------ ios/Sources/KeystorePlugin/PluginShim.swift | 43 ++++++---- 4 files changed, 65 insertions(+), 88 deletions(-) diff --git a/ios/Package.swift b/ios/Package.swift index d2ee6a5..a392fc8 100644 --- a/ios/Package.swift +++ b/ios/Package.swift @@ -1,5 +1,4 @@ - -// swift-tools-version: 5.10 +// swift-tools-version:5.9 import PackageDescription let package = Package( @@ -8,20 +7,10 @@ let package = Package( .iOS(.v17) ], products: [ - .library( - name: "KeystorePlugin", - targets: ["KeystorePlugin"] - ) + .library(name: "KeystorePlugin", targets: ["KeystorePlugin"]) ], targets: [ - .target( - name: "KeystorePlugin", - path: "Sources/KeystorePlugin" - ), - .testTarget( - name: "KeystorePluginTests", - dependencies: ["KeystorePlugin"], - path: "Tests/KeystorePluginTests" - ) + .target(name: "KeystorePlugin", path: "Sources/KeystorePlugin"), + .testTarget(name: "KeystorePluginTests", dependencies: ["KeystorePlugin"], path: "Tests/KeystorePluginTests") ] ) diff --git a/ios/Sources/KeystorePlugin/KeychainHelper.swift b/ios/Sources/KeystorePlugin/KeychainHelper.swift index 64f4c0b..b67b0de 100644 --- a/ios/Sources/KeystorePlugin/KeychainHelper.swift +++ b/ios/Sources/KeystorePlugin/KeychainHelper.swift @@ -13,7 +13,6 @@ final class KeychainHelper { static let service = "com.0x330a.tauri.keystore" static func saveGenericPassword(account: String, data: Data, access: SecAccessControl) throws { - // Delete if exists _ = try? deleteGenericPassword(account: account) let query: [String: Any] = [ @@ -58,15 +57,12 @@ final class KeychainHelper { kSecAttrSynchronizable as String: kCFBooleanFalse as Any ] let status = SecItemDelete(query as CFDictionary) - guard status == errSecSuccess else { + guard status == errSecSuccess || status == errSecItemNotFound else { throw KeychainError.unhandledOSStatus(status) } } - // MARK: - Secure Enclave P-256 Private Key - static func createOrLoadSecureEnclavePrivateKey(tag: Data, access: SecAccessControl) throws -> SecKey { - // Try load existing if let existing = try? loadPrivateKey(tag: tag, context: nil) { return existing } diff --git a/ios/Sources/KeystorePlugin/KeystoreCore.swift b/ios/Sources/KeystorePlugin/KeystoreCore.swift index 462c7e9..2925146 100644 --- a/ios/Sources/KeystorePlugin/KeystoreCore.swift +++ b/ios/Sources/KeystorePlugin/KeystoreCore.swift @@ -6,66 +6,46 @@ import LocalAuthentication public struct KeystoreResult: Encodable { public let ok: Bool - public let result: T? + public let data: T? public let error: String? - public init(ok: Bool, result: T? = nil, error: String? = nil) { + public init(ok: Bool, data: T? = nil, error: String? = nil) { self.ok = ok - self.result = result + self.data = data self.error = error } } public final class KeystoreCore { public static let shared = KeystoreCore() - private let securePrefs = UserDefaults(suiteName: "secure_storage")! private let plainPrefs = UserDefaults(suiteName: "unencrypted_store")! - - // Key identifiers private let symEncAccount = "sym.enc" private let symHmacAccount = "sym.hmac" private let ecdhTag = "se.ecdh.private".data(using: .utf8)! private init() {} - // MARK: - Public API (mirrors Android) - - // contains_key(key: String) public func contains_key(_ key: String) -> KeystoreResult { let hasIv = securePrefs.string(forKey: "iv-\(key)") != nil let hasCt = securePrefs.string(forKey: "ciphertext-\(key)") != nil - return KeystoreResult(ok: true, result: hasIv && hasCt) + return KeystoreResult(ok: true, data: hasIv && hasCt) } - // contains_unencrypted_key(key: String) public func contains_unencrypted_key(_ key: String) -> KeystoreResult { let exists = plainPrefs.object(forKey: key) != nil - return KeystoreResult(ok: true, result: exists) + return KeystoreResult(ok: true, data: exists) } - // store_unencrypted(key: String, value: String) public func store_unencrypted(_ key: String, value: String) -> KeystoreResult { plainPrefs.setValue(value, forKey: key) - return KeystoreResult(ok: true, result: true) - } - - // retrieve_unencrypted(key: String) - public func retrieve_unencrypted(_ key: String) -> KeystoreResult { - guard let v = plainPrefs.string(forKey: key) else { - return KeystoreResult(ok: false, result: nil, error: "not_found") - } - return KeystoreResult(ok: true, result: v) + return KeystoreResult(ok: true, data: true) } - // remove(key: String) - public func remove(_ key: String) -> KeystoreResult { - securePrefs.removeObject(forKey: "iv-\(key)") - securePrefs.removeObject(forKey: "ciphertext-\(key)") - plainPrefs.removeObject(forKey: key) - return KeystoreResult(ok: true, result: true) + public func retrieve_unencrypted(_ key: String) -> KeystoreResult { + let v = plainPrefs.string(forKey: key) + return KeystoreResult(ok: true, data: v) } - // store(key: String, plaintext: String) public func store(_ key: String, plaintext: String) -> KeystoreResult { do { let ctx = LAContext() @@ -78,51 +58,54 @@ public final class KeystoreCore { let ctB64 = ct.base64EncodedString() securePrefs.setValue(ivB64, forKey: "iv-\(key)") securePrefs.setValue(ctB64, forKey: "ciphertext-\(key)") - return KeystoreResult(ok: true, result: true) + return KeystoreResult(ok: true, data: true) } catch { - return KeystoreResult(ok: false, result: nil, error: String(describing: error)) + return KeystoreResult(ok: false, data: nil, error: String(describing: error)) } } - // retrieve(key: String) - public func retrieve(_ key: String) -> KeystoreResult { + public func retrieve(_ key: String) -> KeystoreResult { do { guard let ivB64 = securePrefs.string(forKey: "iv-\(key)"), let ctB64 = securePrefs.string(forKey: "ciphertext-\(key)"), let iv = Data(base64Encoded: ivB64), let ct = Data(base64Encoded: ctB64) else { - return KeystoreResult(ok: false, result: nil, error: "not_found") + return KeystoreResult(ok: true, data: nil) } let ctx = LAContext() ctx.localizedReason = "Unlock to access encryption key" let encKey = try loadOrCreateSymmetricKey(account: symEncAccount, context: ctx) - guard iv.count == 12 else { return KeystoreResult(ok: false, result: nil, error: "bad_iv_length") } + guard iv.count == 12 else { return KeystoreResult(ok: false, data: nil, error: "bad_iv_length") } let nonce = try AES.GCM.Nonce(data: iv) let ctOnly = ct.prefix(ct.count - 16) let tag = ct.suffix(16) let sealed = try AES.GCM.SealedBox(nonce: nonce, ciphertext: ctOnly, tag: tag) let plaintext = try AES.GCM.open(sealed, using: encKey) - return KeystoreResult(ok: true, result: String(data: plaintext, encoding: .utf8) ?? "") + return KeystoreResult(ok: true, data: String(data: plaintext, encoding: .utf8) ?? "") } catch { - return KeystoreResult(ok: false, result: nil, error: String(describing: error)) + return KeystoreResult(ok: false, data: nil, error: String(describing: error)) } } - // hmac_sha256(message: String) -> hex + public func remove(_ key: String) -> KeystoreResult { + securePrefs.removeObject(forKey: "iv-\(key)") + securePrefs.removeObject(forKey: "ciphertext-\(key)") + plainPrefs.removeObject(forKey: key) + return KeystoreResult(ok: true, data: true) + } + public func hmac_sha256(_ message: String) -> KeystoreResult { do { let ctx = LAContext() ctx.localizedReason = "Unlock to access HMAC key" let key = try loadOrCreateSymmetricKey(account: symHmacAccount, context: ctx) let tag = HMAC.authenticationCode(for: Data(message.utf8), using: key) - let hex = tag.map { String(format: "%02x", $0) }.joined() - return KeystoreResult(ok: true, result: hex) + return KeystoreResult(ok: true, data: dataToHex(Data(tag))) } catch { - return KeystoreResult(ok: false, result: nil, error: String(describing: error)) + return KeystoreResult(ok: false, data: nil, error: String(describing: error)) } } - // shared_secret_pub_key() -> hex (uncompressed 04||X||Y) public func shared_secret_pub_key() -> KeystoreResult { do { let ctx = LAContext() @@ -130,13 +113,12 @@ public final class KeystoreCore { let priv = try loadOrCreateECPrivateKey(context: ctx) let pub = try KeychainHelper.publicKey(for: priv) let pubData = try KeychainHelper.publicKeyX963Data(for: pub) - return KeystoreResult(ok: true, result: dataToHex(pubData)) + return KeystoreResult(ok: true, data: dataToHex(pubData)) } catch { - return KeystoreResult(ok: false, result: nil, error: String(describing: error)) + return KeystoreResult(ok: false, data: nil, error: String(describing: error)) } } - // shared_secret(pubKeys: [hex]) -> [hex] public func shared_secret(_ pubKeys: [String]) -> KeystoreResult<[String]> { do { let ctx = LAContext() @@ -145,7 +127,7 @@ public final class KeystoreCore { var results: [String] = [] for hex in pubKeys { guard let peerX963 = hexToData(hex) else { - return KeystoreResult(ok: false, result: nil, error: "bad_pubkey_hex") + return KeystoreResult(ok: false, data: nil, error: "bad_pubkey_hex") } let attrs: [String: Any] = [ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, @@ -156,27 +138,24 @@ public final class KeystoreCore { guard let peerKey = SecKeyCreateWithData(peerX963 as CFData, attrs as CFDictionary, &err) else { throw err!.takeRetainedValue() as Error } - let params: [String: Any] = [:] // no KDF var error: Unmanaged? - guard let secret = SecKeyCopyKeyExchangeResult(priv, SecKeyAlgorithm.ecdhKeyExchangeStandard, peerKey, params as CFDictionary, &error) as Data? else { + guard let secret = SecKeyCopyKeyExchangeResult(priv, SecKeyAlgorithm.ecdhKeyExchangeStandard, peerKey, [:] as CFDictionary, &error) as Data? else { throw error!.takeRetainedValue() as Error } results.append(dataToHex(secret)) } - return KeystoreResult(ok: true, result: results) + return KeystoreResult(ok: true, data: results) } catch { - return KeystoreResult(ok: false, result: nil, error: String(describing: error)) + return KeystoreResult(ok: false, data: nil, error: String(describing: error)) } } - // MARK: - Internals + // Internals private func loadOrCreateSymmetricKey(account: String, context: LAContext?) throws -> SymmetricKey { - // Try load if let data = try? KeychainHelper.retrieveGenericPassword(account: account, context: context) { return SymmetricKey(data: data) } - // Create let key = SymmetricKey(size: .bits256) let data = key.withUnsafeBytes { Data($0) } let access = try makeAccessControl(requirePrivateKeyUsage: false) diff --git a/ios/Sources/KeystorePlugin/PluginShim.swift b/ios/Sources/KeystorePlugin/PluginShim.swift index 46c4415..05e2308 100644 --- a/ios/Sources/KeystorePlugin/PluginShim.swift +++ b/ios/Sources/KeystorePlugin/PluginShim.swift @@ -3,41 +3,36 @@ import Foundation #if canImport(Tauri) import Tauri +import SwiftRs @objc(KeystorePlugin) public class KeystorePlugin: Plugin { private let core = KeystoreCore.shared - @objc public func containsKey(_ invoke: Invoke) throws { + @objc public func contains_key(_ invoke: Invoke) throws { struct Args: Decodable { let key: String } let args: Args = try invoke.parseArgs() invoke.resolve(core.contains_key(args.key)) } - @objc public func containsUnencryptedKey(_ invoke: Invoke) throws { + @objc public func contains_unencrypted_key(_ invoke: Invoke) throws { struct Args: Decodable { let key: String } let args: Args = try invoke.parseArgs() invoke.resolve(core.contains_unencrypted_key(args.key)) } - @objc public func storeUnencrypted(_ invoke: Invoke) throws { + @objc public func store_unencrypted(_ invoke: Invoke) throws { struct Args: Decodable { let key: String; let value: String } let args: Args = try invoke.parseArgs() invoke.resolve(core.store_unencrypted(args.key, value: args.value)) } - @objc public func retrieveUnencrypted(_ invoke: Invoke) throws { + @objc public func retrieve_unencrypted(_ invoke: Invoke) throws { struct Args: Decodable { let key: String } let args: Args = try invoke.parseArgs() invoke.resolve(core.retrieve_unencrypted(args.key)) } - @objc public func remove(_ invoke: Invoke) throws { - struct Args: Decodable { let key: String } - let args: Args = try invoke.parseArgs() - invoke.resolve(core.remove(args.key)) - } - @objc public func store(_ invoke: Invoke) throws { struct Args: Decodable { let key: String; let plaintext: String } let args: Args = try invoke.parseArgs() @@ -50,20 +45,38 @@ public class KeystorePlugin: Plugin { invoke.resolve(core.retrieve(args.key)) } - @objc public func hmacSha256(_ invoke: Invoke) throws { + @objc public func remove(_ invoke: Invoke) throws { + struct Args: Decodable { let key: String } + let args: Args = try invoke.parseArgs() + invoke.resolve(core.remove(args.key)) + } + + @objc public func hmac_sha256(_ invoke: Invoke) throws { struct Args: Decodable { let message: String } let args: Args = try invoke.parseArgs() invoke.resolve(core.hmac_sha256(args.message)) } - @objc public func sharedSecretPubKey(_ invoke: Invoke) throws { + @objc public func shared_secret_pub_key(_ invoke: Invoke) throws { invoke.resolve(core.shared_secret_pub_key()) } - @objc public func sharedSecret(_ invoke: Invoke) throws { - struct Args: Decodable { let pubKeys: [String] } + @objc public func shared_secret(_ invoke: Invoke) throws { + struct Args: Decodable { let withP256PubKeys: [String] } let args: Args = try invoke.parseArgs() - invoke.resolve(core.shared_secret(args.pubKeys)) + invoke.resolve(core.shared_secret(args.withP256PubKeys)) } } + +@_cdecl("init_plugin_keystore") +public func initPluginKeystore() -> UnsafeMutableRawPointer? { + let plugin = KeystorePlugin() + let unmanaged = Unmanaged.passRetained(plugin) + return UnsafeMutableRawPointer(unmanaged.toOpaque()) +} +#else +@_cdecl("init_plugin_keystore") +public func initPluginKeystore() -> UnsafeMutableRawPointer? { + return nil +} #endif From 1aca0cf6b9236c83b2dd0b7ae075b4009d7f24a9 Mon Sep 17 00:00:00 2001 From: Paul Salisbury Date: Thu, 21 Aug 2025 21:35:57 +1200 Subject: [PATCH 23/33] fix: test --- ios/Tests/KeystorePluginTests/KeystorePluginTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Tests/KeystorePluginTests/KeystorePluginTests.swift b/ios/Tests/KeystorePluginTests/KeystorePluginTests.swift index 505bae6..2890765 100644 --- a/ios/Tests/KeystorePluginTests/KeystorePluginTests.swift +++ b/ios/Tests/KeystorePluginTests/KeystorePluginTests.swift @@ -15,6 +15,6 @@ final class KeystorePluginTests: XCTestCase { _ = core.store_unencrypted("hello", value: "world") let res = core.retrieve_unencrypted("hello") XCTAssertTrue(res.ok) - XCTAssertEqual(res.result, "world") + XCTAssertEqual(res.data, "world") } } From b9df72a024be13fd9209ded0af1a62be369a6e78 Mon Sep 17 00:00:00 2001 From: Paul Salisbury Date: Thu, 21 Aug 2025 22:15:25 +1200 Subject: [PATCH 24/33] fix: initPlugin return KeystorePlugin --- ios/Sources/KeystorePlugin/PluginShim.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/ios/Sources/KeystorePlugin/PluginShim.swift b/ios/Sources/KeystorePlugin/PluginShim.swift index 05e2308..35b1d7e 100644 --- a/ios/Sources/KeystorePlugin/PluginShim.swift +++ b/ios/Sources/KeystorePlugin/PluginShim.swift @@ -69,11 +69,10 @@ public class KeystorePlugin: Plugin { } @_cdecl("init_plugin_keystore") -public func initPluginKeystore() -> UnsafeMutableRawPointer? { - let plugin = KeystorePlugin() - let unmanaged = Unmanaged.passRetained(plugin) - return UnsafeMutableRawPointer(unmanaged.toOpaque()) +func initPlugin() -> Plugin { + return KeystorePlugin() } + #else @_cdecl("init_plugin_keystore") public func initPluginKeystore() -> UnsafeMutableRawPointer? { From 4a0c289e44185b6124baf66d4951e5c6813f8528 Mon Sep 17 00:00:00 2001 From: Paul Salisbury Date: Fri, 22 Aug 2025 00:06:23 +1200 Subject: [PATCH 25/33] fix: flag iOS 17 using available --- ios/Sources/KeystorePlugin/KeystoreCore.swift | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/ios/Sources/KeystorePlugin/KeystoreCore.swift b/ios/Sources/KeystorePlugin/KeystoreCore.swift index 2925146..c205d10 100644 --- a/ios/Sources/KeystorePlugin/KeystoreCore.swift +++ b/ios/Sources/KeystorePlugin/KeystoreCore.swift @@ -1,8 +1,7 @@ - -import Foundation import CryptoKit -import Security +import Foundation import LocalAuthentication +import Security public struct KeystoreResult: Encodable { public let ok: Bool @@ -15,6 +14,7 @@ public struct KeystoreResult: Encodable { } } +@available(iOS 17, *) public final class KeystoreCore { public static let shared = KeystoreCore() private let securePrefs = UserDefaults(suiteName: "secure_storage")! @@ -67,15 +67,18 @@ public final class KeystoreCore { public func retrieve(_ key: String) -> KeystoreResult { do { guard let ivB64 = securePrefs.string(forKey: "iv-\(key)"), - let ctB64 = securePrefs.string(forKey: "ciphertext-\(key)"), - let iv = Data(base64Encoded: ivB64), - let ct = Data(base64Encoded: ctB64) else { + let ctB64 = securePrefs.string(forKey: "ciphertext-\(key)"), + let iv = Data(base64Encoded: ivB64), + let ct = Data(base64Encoded: ctB64) + else { return KeystoreResult(ok: true, data: nil) } let ctx = LAContext() ctx.localizedReason = "Unlock to access encryption key" let encKey = try loadOrCreateSymmetricKey(account: symEncAccount, context: ctx) - guard iv.count == 12 else { return KeystoreResult(ok: false, data: nil, error: "bad_iv_length") } + guard iv.count == 12 else { + return KeystoreResult(ok: false, data: nil, error: "bad_iv_length") + } let nonce = try AES.GCM.Nonce(data: iv) let ctOnly = ct.prefix(ct.count - 16) let tag = ct.suffix(16) @@ -132,14 +135,21 @@ public final class KeystoreCore { let attrs: [String: Any] = [ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, kSecAttrKeyClass as String: kSecAttrKeyClassPublic, - kSecAttrKeySizeInBits as String: 256 + kSecAttrKeySizeInBits as String: 256, ] var err: Unmanaged? - guard let peerKey = SecKeyCreateWithData(peerX963 as CFData, attrs as CFDictionary, &err) else { + guard + let peerKey = SecKeyCreateWithData( + peerX963 as CFData, attrs as CFDictionary, &err) + else { throw err!.takeRetainedValue() as Error } var error: Unmanaged? - guard let secret = SecKeyCopyKeyExchangeResult(priv, SecKeyAlgorithm.ecdhKeyExchangeStandard, peerKey, [:] as CFDictionary, &error) as Data? else { + guard + let secret = SecKeyCopyKeyExchangeResult( + priv, SecKeyAlgorithm.ecdhKeyExchangeStandard, peerKey, [:] as CFDictionary, + &error) as Data? + else { throw error!.takeRetainedValue() as Error } results.append(dataToHex(secret)) @@ -152,8 +162,12 @@ public final class KeystoreCore { // Internals - private func loadOrCreateSymmetricKey(account: String, context: LAContext?) throws -> SymmetricKey { - if let data = try? KeychainHelper.retrieveGenericPassword(account: account, context: context) { + private func loadOrCreateSymmetricKey(account: String, context: LAContext?) throws + -> SymmetricKey + { + if let data = try? KeychainHelper.retrieveGenericPassword( + account: account, context: context) + { return SymmetricKey(data: data) } let key = SymmetricKey(size: .bits256) @@ -174,7 +188,10 @@ public final class KeystoreCore { flags.insert(.privateKeyUsage) } var error: Unmanaged? - guard let ac = SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, flags, &error) else { + guard + let ac = SecAccessControlCreateWithFlags( + nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, flags, &error) + else { throw error!.takeRetainedValue() as Error } return ac From 2fc3597b6d03b77716a988ff1c36a8ecdbc55bfd Mon Sep 17 00:00:00 2001 From: Paul Salisbury Date: Fri, 22 Aug 2025 10:40:08 +1200 Subject: [PATCH 26/33] chore: cleanup package.swift --- ios/Package.swift | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/ios/Package.swift b/ios/Package.swift index a392fc8..54a38dd 100644 --- a/ios/Package.swift +++ b/ios/Package.swift @@ -2,15 +2,26 @@ import PackageDescription let package = Package( - name: "KeystorePlugin", + name: "tauri-plugin-keystore", platforms: [ .iOS(.v17) ], products: [ - .library(name: "KeystorePlugin", targets: ["KeystorePlugin"]) + .library( + name: "tauri-plugin-keystore", + type: .static, + targets: ["tauri-plugin-keystore"]), ], targets: [ - .target(name: "KeystorePlugin", path: "Sources/KeystorePlugin"), - .testTarget(name: "KeystorePluginTests", dependencies: ["KeystorePlugin"], path: "Tests/KeystorePluginTests") + .target( + name: "tauri-plugin-keystore", + dependencies: [ + .byName(name: "Tauri") + ], + path: "Sources/KeystorePlugin"), + .testTarget( + name: "KeystorePluginTests", + dependencies: ["tauri-plugin-keystore"], + path: "Tests/KeystorePluginTests") ] ) From d585dd6a3a8b00f00a94bcb85856d708320bee86 Mon Sep 17 00:00:00 2001 From: Paul Salisbury Date: Fri, 22 Aug 2025 11:13:33 +1200 Subject: [PATCH 27/33] fix: resolve tauri-api dep path --- ios/Package.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ios/Package.swift b/ios/Package.swift index 54a38dd..dbf9971 100644 --- a/ios/Package.swift +++ b/ios/Package.swift @@ -12,6 +12,9 @@ let package = Package( type: .static, targets: ["tauri-plugin-keystore"]), ], + dependencies: [ + .package(name: "Tauri", path: "../.tauri/tauri-api") + ], targets: [ .target( name: "tauri-plugin-keystore", From 45de700cceef6af258810a2f6fa1410d30ff7e82 Mon Sep 17 00:00:00 2001 From: Paul Salisbury Date: Fri, 22 Aug 2025 14:21:48 +1200 Subject: [PATCH 28/33] fix: proper parsing of args when invoked --- ios/Sources/KeystorePlugin/PluginShim.swift | 51 ++++++++++++++++----- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/ios/Sources/KeystorePlugin/PluginShim.swift b/ios/Sources/KeystorePlugin/PluginShim.swift index 35b1d7e..45ac0c4 100644 --- a/ios/Sources/KeystorePlugin/PluginShim.swift +++ b/ios/Sources/KeystorePlugin/PluginShim.swift @@ -9,70 +9,97 @@ import SwiftRs public class KeystorePlugin: Plugin { private let core = KeystoreCore.shared + // MARK: - Helpers + + /// Tries to parse args to the requested Decodable type. + /// If parsing fails, rejects the invoke and returns nil. + private func parseOrReject(_ type: T.Type, _ invoke: Invoke) -> T? { + do { + return try invoke.parseArgs(T.self) + } catch { + invoke.reject("invalid_args: \(error)") + return nil + } + } + + // MARK: - Commands (argument handling aligned with Android plugin) + + /// contains_key(key: String) -> Bool @objc public func contains_key(_ invoke: Invoke) throws { struct Args: Decodable { let key: String } - let args: Args = try invoke.parseArgs() + guard let args: Args = parseOrReject(Args.self, invoke) else { return } invoke.resolve(core.contains_key(args.key)) } + /// contains_unencrypted_key(key: String) -> Bool @objc public func contains_unencrypted_key(_ invoke: Invoke) throws { struct Args: Decodable { let key: String } - let args: Args = try invoke.parseArgs() + guard let args: Args = parseOrReject(Args.self, invoke) else { return } invoke.resolve(core.contains_unencrypted_key(args.key)) } + /// store_unencrypted(key: String, value: String) -> Bool @objc public func store_unencrypted(_ invoke: Invoke) throws { struct Args: Decodable { let key: String; let value: String } - let args: Args = try invoke.parseArgs() + guard let args: Args = parseOrReject(Args.self, invoke) else { return } invoke.resolve(core.store_unencrypted(args.key, value: args.value)) } + /// retrieve_unencrypted(key: String) -> String? @objc public func retrieve_unencrypted(_ invoke: Invoke) throws { struct Args: Decodable { let key: String } - let args: Args = try invoke.parseArgs() + guard let args: Args = parseOrReject(Args.self, invoke) else { return } invoke.resolve(core.retrieve_unencrypted(args.key)) } + /// store(key: String, plaintext: String) -> Bool @objc public func store(_ invoke: Invoke) throws { struct Args: Decodable { let key: String; let plaintext: String } - let args: Args = try invoke.parseArgs() + guard let args: Args = parseOrReject(Args.self, invoke) else { return } invoke.resolve(core.store(args.key, plaintext: args.plaintext)) } + /// retrieve(key: String) -> String? @objc public func retrieve(_ invoke: Invoke) throws { struct Args: Decodable { let key: String } - let args: Args = try invoke.parseArgs() + guard let args: Args = parseOrReject(Args.self, invoke) else { return } invoke.resolve(core.retrieve(args.key)) } + /// remove(key: String) -> Bool @objc public func remove(_ invoke: Invoke) throws { struct Args: Decodable { let key: String } - let args: Args = try invoke.parseArgs() + guard let args: Args = parseOrReject(Args.self, invoke) else { return } invoke.resolve(core.remove(args.key)) } + /// hmac_sha256(message: String) -> hex String @objc public func hmac_sha256(_ invoke: Invoke) throws { struct Args: Decodable { let message: String } - let args: Args = try invoke.parseArgs() + guard let args: Args = parseOrReject(Args.self, invoke) else { return } invoke.resolve(core.hmac_sha256(args.message)) } + /// shared_secret_pub_key() -> hex String (no args) @objc public func shared_secret_pub_key(_ invoke: Invoke) throws { + // No args are expected; do not call parseArgs() here. invoke.resolve(core.shared_secret_pub_key()) } + /// shared_secret(withP256PubKeys: [String]) -> [hex String] @objc public func shared_secret(_ invoke: Invoke) throws { struct Args: Decodable { let withP256PubKeys: [String] } - let args: Args = try invoke.parseArgs() + guard let args: Args = parseOrReject(Args.self, invoke) else { return } invoke.resolve(core.shared_secret(args.withP256PubKeys)) } } @_cdecl("init_plugin_keystore") -func initPlugin() -> Plugin { - return KeystorePlugin() +public func initPluginKeystore() -> UnsafeMutableRawPointer? { + let plugin = KeystorePlugin() + let unmanaged = Unmanaged.passRetained(plugin) + return UnsafeMutableRawPointer(unmanaged.toOpaque()) } - #else @_cdecl("init_plugin_keystore") public func initPluginKeystore() -> UnsafeMutableRawPointer? { From bf2a11fbb24a5a4c893d11c58695661932fe915b Mon Sep 17 00:00:00 2001 From: Paul Salisbury Date: Tue, 26 Aug 2025 10:24:45 +1200 Subject: [PATCH 29/33] test without parse args guard --- ios/Sources/KeystorePlugin/PluginShim.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/ios/Sources/KeystorePlugin/PluginShim.swift b/ios/Sources/KeystorePlugin/PluginShim.swift index 45ac0c4..9ce8121 100644 --- a/ios/Sources/KeystorePlugin/PluginShim.swift +++ b/ios/Sources/KeystorePlugin/PluginShim.swift @@ -27,56 +27,56 @@ public class KeystorePlugin: Plugin { /// contains_key(key: String) -> Bool @objc public func contains_key(_ invoke: Invoke) throws { struct Args: Decodable { let key: String } - guard let args: Args = parseOrReject(Args.self, invoke) else { return } + //guard let args: Args = parseOrReject(Args.self, invoke) else { return } invoke.resolve(core.contains_key(args.key)) } /// contains_unencrypted_key(key: String) -> Bool @objc public func contains_unencrypted_key(_ invoke: Invoke) throws { struct Args: Decodable { let key: String } - guard let args: Args = parseOrReject(Args.self, invoke) else { return } + //guard let args: Args = parseOrReject(Args.self, invoke) else { return } invoke.resolve(core.contains_unencrypted_key(args.key)) } /// store_unencrypted(key: String, value: String) -> Bool @objc public func store_unencrypted(_ invoke: Invoke) throws { struct Args: Decodable { let key: String; let value: String } - guard let args: Args = parseOrReject(Args.self, invoke) else { return } + //guard let args: Args = parseOrReject(Args.self, invoke) else { return } invoke.resolve(core.store_unencrypted(args.key, value: args.value)) } /// retrieve_unencrypted(key: String) -> String? @objc public func retrieve_unencrypted(_ invoke: Invoke) throws { struct Args: Decodable { let key: String } - guard let args: Args = parseOrReject(Args.self, invoke) else { return } + //guard let args: Args = parseOrReject(Args.self, invoke) else { return } invoke.resolve(core.retrieve_unencrypted(args.key)) } /// store(key: String, plaintext: String) -> Bool @objc public func store(_ invoke: Invoke) throws { struct Args: Decodable { let key: String; let plaintext: String } - guard let args: Args = parseOrReject(Args.self, invoke) else { return } + //guard let args: Args = parseOrReject(Args.self, invoke) else { return } invoke.resolve(core.store(args.key, plaintext: args.plaintext)) } /// retrieve(key: String) -> String? @objc public func retrieve(_ invoke: Invoke) throws { struct Args: Decodable { let key: String } - guard let args: Args = parseOrReject(Args.self, invoke) else { return } + //guard let args: Args = parseOrReject(Args.self, invoke) else { return } invoke.resolve(core.retrieve(args.key)) } /// remove(key: String) -> Bool @objc public func remove(_ invoke: Invoke) throws { struct Args: Decodable { let key: String } - guard let args: Args = parseOrReject(Args.self, invoke) else { return } + //guard let args: Args = parseOrReject(Args.self, invoke) else { return } invoke.resolve(core.remove(args.key)) } /// hmac_sha256(message: String) -> hex String @objc public func hmac_sha256(_ invoke: Invoke) throws { struct Args: Decodable { let message: String } - guard let args: Args = parseOrReject(Args.self, invoke) else { return } + //guard let args: Args = parseOrReject(Args.self, invoke) else { return } invoke.resolve(core.hmac_sha256(args.message)) } @@ -89,7 +89,7 @@ public class KeystorePlugin: Plugin { /// shared_secret(withP256PubKeys: [String]) -> [hex String] @objc public func shared_secret(_ invoke: Invoke) throws { struct Args: Decodable { let withP256PubKeys: [String] } - guard let args: Args = parseOrReject(Args.self, invoke) else { return } + //guard let args: Args = parseOrReject(Args.self, invoke) else { return } invoke.resolve(core.shared_secret(args.withP256PubKeys)) } } From c4f8f734560352bbcb838e38bf4911d76487d99c Mon Sep 17 00:00:00 2001 From: Paul Salisbury Date: Tue, 26 Aug 2025 12:37:26 +1200 Subject: [PATCH 30/33] chore: set static service and account todo: use config args --- ios/Sources/KeystorePlugin/KeychainHelper.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ios/Sources/KeystorePlugin/KeychainHelper.swift b/ios/Sources/KeystorePlugin/KeychainHelper.swift index b67b0de..2b7c22e 100644 --- a/ios/Sources/KeystorePlugin/KeychainHelper.swift +++ b/ios/Sources/KeystorePlugin/KeychainHelper.swift @@ -10,7 +10,8 @@ enum KeychainError: Error { } final class KeychainHelper { - static let service = "com.0x330a.tauri.keystore" + static let service = "app.tauri.keystore" + static let account = "" static func saveGenericPassword(account: String, data: Data, access: SecAccessControl) throws { _ = try? deleteGenericPassword(account: account) From 910991727540b2584c822b6ea3541bfe363cb4c4 Mon Sep 17 00:00:00 2001 From: Paul Salisbury Date: Tue, 26 Aug 2025 15:42:51 +1200 Subject: [PATCH 31/33] test without parse args guard --- ios/Sources/KeystorePlugin/PluginShim.swift | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/ios/Sources/KeystorePlugin/PluginShim.swift b/ios/Sources/KeystorePlugin/PluginShim.swift index 9ce8121..c3c5495 100644 --- a/ios/Sources/KeystorePlugin/PluginShim.swift +++ b/ios/Sources/KeystorePlugin/PluginShim.swift @@ -1,12 +1,11 @@ import Foundation - #if canImport(Tauri) import Tauri import SwiftRs +#endif -@objc(KeystorePlugin) -public class KeystorePlugin: Plugin { +class KeystorePlugin: Plugin { private let core = KeystoreCore.shared // MARK: - Helpers @@ -28,6 +27,7 @@ public class KeystorePlugin: Plugin { @objc public func contains_key(_ invoke: Invoke) throws { struct Args: Decodable { let key: String } //guard let args: Args = parseOrReject(Args.self, invoke) else { return } + let args: Args invoke.resolve(core.contains_key(args.key)) } @@ -35,6 +35,7 @@ public class KeystorePlugin: Plugin { @objc public func contains_unencrypted_key(_ invoke: Invoke) throws { struct Args: Decodable { let key: String } //guard let args: Args = parseOrReject(Args.self, invoke) else { return } + let args: Args invoke.resolve(core.contains_unencrypted_key(args.key)) } @@ -42,6 +43,7 @@ public class KeystorePlugin: Plugin { @objc public func store_unencrypted(_ invoke: Invoke) throws { struct Args: Decodable { let key: String; let value: String } //guard let args: Args = parseOrReject(Args.self, invoke) else { return } + let args: Args invoke.resolve(core.store_unencrypted(args.key, value: args.value)) } @@ -49,6 +51,7 @@ public class KeystorePlugin: Plugin { @objc public func retrieve_unencrypted(_ invoke: Invoke) throws { struct Args: Decodable { let key: String } //guard let args: Args = parseOrReject(Args.self, invoke) else { return } + let args: Args invoke.resolve(core.retrieve_unencrypted(args.key)) } @@ -56,6 +59,7 @@ public class KeystorePlugin: Plugin { @objc public func store(_ invoke: Invoke) throws { struct Args: Decodable { let key: String; let plaintext: String } //guard let args: Args = parseOrReject(Args.self, invoke) else { return } + let args: Args invoke.resolve(core.store(args.key, plaintext: args.plaintext)) } @@ -63,6 +67,7 @@ public class KeystorePlugin: Plugin { @objc public func retrieve(_ invoke: Invoke) throws { struct Args: Decodable { let key: String } //guard let args: Args = parseOrReject(Args.self, invoke) else { return } + let args: Args invoke.resolve(core.retrieve(args.key)) } @@ -70,6 +75,7 @@ public class KeystorePlugin: Plugin { @objc public func remove(_ invoke: Invoke) throws { struct Args: Decodable { let key: String } //guard let args: Args = parseOrReject(Args.self, invoke) else { return } + let args: Args invoke.resolve(core.remove(args.key)) } @@ -77,6 +83,7 @@ public class KeystorePlugin: Plugin { @objc public func hmac_sha256(_ invoke: Invoke) throws { struct Args: Decodable { let message: String } //guard let args: Args = parseOrReject(Args.self, invoke) else { return } + let args: Args invoke.resolve(core.hmac_sha256(args.message)) } @@ -90,6 +97,7 @@ public class KeystorePlugin: Plugin { @objc public func shared_secret(_ invoke: Invoke) throws { struct Args: Decodable { let withP256PubKeys: [String] } //guard let args: Args = parseOrReject(Args.self, invoke) else { return } + let args: Args invoke.resolve(core.shared_secret(args.withP256PubKeys)) } } @@ -100,9 +108,3 @@ public func initPluginKeystore() -> UnsafeMutableRawPointer? { let unmanaged = Unmanaged.passRetained(plugin) return UnsafeMutableRawPointer(unmanaged.toOpaque()) } -#else -@_cdecl("init_plugin_keystore") -public func initPluginKeystore() -> UnsafeMutableRawPointer? { - return nil -} -#endif From 29641b92e6267ac6d26506af237fcb2d7c195bd6 Mon Sep 17 00:00:00 2001 From: Paul Salisbury Date: Tue, 26 Aug 2025 16:15:22 +1200 Subject: [PATCH 32/33] test without parse args guard --- ios/Sources/KeystorePlugin/PluginShim.swift | 92 +++++++++++---------- 1 file changed, 50 insertions(+), 42 deletions(-) diff --git a/ios/Sources/KeystorePlugin/PluginShim.swift b/ios/Sources/KeystorePlugin/PluginShim.swift index c3c5495..ccfea75 100644 --- a/ios/Sources/KeystorePlugin/PluginShim.swift +++ b/ios/Sources/KeystorePlugin/PluginShim.swift @@ -5,85 +5,94 @@ import Tauri import SwiftRs #endif -class KeystorePlugin: Plugin { - private let core = KeystoreCore.shared +class ContainsKey: Decodable { + let key: String +} - // MARK: - Helpers - - /// Tries to parse args to the requested Decodable type. - /// If parsing fails, rejects the invoke and returns nil. - private func parseOrReject(_ type: T.Type, _ invoke: Invoke) -> T? { - do { - return try invoke.parseArgs(T.self) - } catch { - invoke.reject("invalid_args: \(error)") - return nil - } - } +class ContainsUnencryptedKey: Decodable { + let key: String +} + +class StoreUnencrypted: Decodable { + let key: String + let value: String +} + +class RetrieveUnencrypted: Decodable { + let key: String +} + +class Store: Decodable { + let key: String + let plaintext: String +} + +class Retrieve: Decodable { + let key: String +} + +class Remove: Decodable { + let key: String +} + +class HmacSha256: Decodable { + let message: String +} + +class SharedSecret: Decodable { + let withP256PubKeys: [String] +} + +#if canImport(Tauri) - // MARK: - Commands (argument handling aligned with Android plugin) +class KeystorePlugin: Plugin { + private let core = KeystoreCore.shared /// contains_key(key: String) -> Bool @objc public func contains_key(_ invoke: Invoke) throws { - struct Args: Decodable { let key: String } - //guard let args: Args = parseOrReject(Args.self, invoke) else { return } - let args: Args + let args = try invoke.parseArgs(ContainsKey.self) invoke.resolve(core.contains_key(args.key)) } /// contains_unencrypted_key(key: String) -> Bool @objc public func contains_unencrypted_key(_ invoke: Invoke) throws { - struct Args: Decodable { let key: String } - //guard let args: Args = parseOrReject(Args.self, invoke) else { return } - let args: Args + let args = try invoke.parseArgs(ContainsUnencryptedKey.self) invoke.resolve(core.contains_unencrypted_key(args.key)) } /// store_unencrypted(key: String, value: String) -> Bool @objc public func store_unencrypted(_ invoke: Invoke) throws { - struct Args: Decodable { let key: String; let value: String } - //guard let args: Args = parseOrReject(Args.self, invoke) else { return } - let args: Args + let args = try invoke.parseArgs(StoreUnencrypted.self) invoke.resolve(core.store_unencrypted(args.key, value: args.value)) } /// retrieve_unencrypted(key: String) -> String? @objc public func retrieve_unencrypted(_ invoke: Invoke) throws { - struct Args: Decodable { let key: String } - //guard let args: Args = parseOrReject(Args.self, invoke) else { return } - let args: Args + let args = try invoke.parseArgs(RetrieveUnencrypted.self) invoke.resolve(core.retrieve_unencrypted(args.key)) } /// store(key: String, plaintext: String) -> Bool @objc public func store(_ invoke: Invoke) throws { - struct Args: Decodable { let key: String; let plaintext: String } - //guard let args: Args = parseOrReject(Args.self, invoke) else { return } - let args: Args + let args = try invoke.parseArgs(Store.self) invoke.resolve(core.store(args.key, plaintext: args.plaintext)) } /// retrieve(key: String) -> String? @objc public func retrieve(_ invoke: Invoke) throws { - struct Args: Decodable { let key: String } - //guard let args: Args = parseOrReject(Args.self, invoke) else { return } - let args: Args + let args = try invoke.parseArgs(Retrieve.self) invoke.resolve(core.retrieve(args.key)) } /// remove(key: String) -> Bool @objc public func remove(_ invoke: Invoke) throws { - struct Args: Decodable { let key: String } - //guard let args: Args = parseOrReject(Args.self, invoke) else { return } - let args: Args + let args = try invoke.parseArgs(Remove.self) invoke.resolve(core.remove(args.key)) } /// hmac_sha256(message: String) -> hex String @objc public func hmac_sha256(_ invoke: Invoke) throws { - struct Args: Decodable { let message: String } - //guard let args: Args = parseOrReject(Args.self, invoke) else { return } - let args: Args + let args = try invoke.parseArgs(HmacSha256.self) invoke.resolve(core.hmac_sha256(args.message)) } @@ -95,9 +104,7 @@ class KeystorePlugin: Plugin { /// shared_secret(withP256PubKeys: [String]) -> [hex String] @objc public func shared_secret(_ invoke: Invoke) throws { - struct Args: Decodable { let withP256PubKeys: [String] } - //guard let args: Args = parseOrReject(Args.self, invoke) else { return } - let args: Args + let args = try invoke.parseArgs(SharedSecret.self) invoke.resolve(core.shared_secret(args.withP256PubKeys)) } } @@ -108,3 +115,4 @@ public func initPluginKeystore() -> UnsafeMutableRawPointer? { let unmanaged = Unmanaged.passRetained(plugin) return UnsafeMutableRawPointer(unmanaged.toOpaque()) } +#endif From ee90486c98172403cce1bbd65466accf2712960c Mon Sep 17 00:00:00 2001 From: 0x330a <92654767+0x330a@users.noreply.github.com> Date: Mon, 10 Nov 2025 18:25:56 +0700 Subject: [PATCH 33/33] Squashed commit of the following: commit c12bf2544785e3f34b3570011cbd0d23ac73dd2c Author: 0x330a <92654767+0x330a@users.noreply.github.com> Date: Mon Nov 10 18:16:52 2025 +0700 chore: merge ios compatibility # Conflicts: # ios/Package.swift # ios/Sources/KeystorePlugin/KeystoreCore.swift # ios/Sources/KeystorePlugin/PluginShim.swift --- Cargo.toml | 2 +- guest-js/index.ts | 2 +- ios/Package.swift | 2 +- ios/Sources/KeystorePlugin/KeystoreCore.swift | 351 +++++++++++------- ios/Sources/KeystorePlugin/PluginShim.swift | 48 +-- package.json | 2 +- 6 files changed, 255 insertions(+), 152 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 98a4dcc..6aa288f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tauri-plugin-keystore" -version = "2.1.0-alpha.5" +version = "2.1.0-alpha.6" authors = ["daniel-mader"] description = "Interact with the device-native key storage (Android Keystore, iOS Keychain)." edition = "2021" diff --git a/guest-js/index.ts b/guest-js/index.ts index 73eed63..7e65b5f 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -40,7 +40,7 @@ export async function containsKey(key: string): Promise { payload: { key } - }).then() + }) } export async function retrieve(key: string): Promise { diff --git a/ios/Package.swift b/ios/Package.swift index dbf9971..34a68c1 100644 --- a/ios/Package.swift +++ b/ios/Package.swift @@ -4,7 +4,7 @@ import PackageDescription let package = Package( name: "tauri-plugin-keystore", platforms: [ - .iOS(.v17) + .iOS(.v15) ], products: [ .library( diff --git a/ios/Sources/KeystorePlugin/KeystoreCore.swift b/ios/Sources/KeystorePlugin/KeystoreCore.swift index c205d10..d2a5493 100644 --- a/ios/Sources/KeystorePlugin/KeystoreCore.swift +++ b/ios/Sources/KeystorePlugin/KeystoreCore.swift @@ -14,179 +14,280 @@ public struct KeystoreResult: Encodable { } } -@available(iOS 17, *) +@available(iOS 15, *) public final class KeystoreCore { - public static let shared = KeystoreCore() - private let securePrefs = UserDefaults(suiteName: "secure_storage")! + public static let shared = KeystoreCore() // Singleton + private let accessQueue: DispatchQueue = DispatchQueue(label: "app.metasig.keystore.access", attributes: .concurrent) private let plainPrefs = UserDefaults(suiteName: "unencrypted_store")! - private let symEncAccount = "sym.enc" - private let symHmacAccount = "sym.hmac" - private let ecdhTag = "se.ecdh.private".data(using: .utf8)! - + private let keychainServiceGroupName = "app.metasig.keystore.encrypted" + let hmacKeyAlias = "app.metasig.hmac.key" + private init() {} - public func contains_key(_ key: String) -> KeystoreResult { - let hasIv = securePrefs.string(forKey: "iv-\(key)") != nil - let hasCt = securePrefs.string(forKey: "ciphertext-\(key)") != nil - return KeystoreResult(ok: true, data: hasIv && hasCt) - } - + /** + * + */ public func contains_unencrypted_key(_ key: String) -> KeystoreResult { let exists = plainPrefs.object(forKey: key) != nil return KeystoreResult(ok: true, data: exists) } + /** + * + */ public func store_unencrypted(_ key: String, value: String) -> KeystoreResult { plainPrefs.setValue(value, forKey: key) return KeystoreResult(ok: true, data: true) } + /** + * + */ public func retrieve_unencrypted(_ key: String) -> KeystoreResult { let v = plainPrefs.string(forKey: key) return KeystoreResult(ok: true, data: v) } - public func store(_ key: String, plaintext: String) -> KeystoreResult { - do { - let ctx = LAContext() - ctx.localizedReason = "Unlock to access encryption key" - let encKey = try loadOrCreateSymmetricKey(account: symEncAccount, context: ctx) - let nonce = AES.GCM.Nonce() - let sealed = try AES.GCM.seal(Data(plaintext.utf8), using: encKey, nonce: nonce) - let ivB64 = Data(nonce.withUnsafeBytes { Data($0) }).base64EncodedString() - let ct = sealed.ciphertext + sealed.tag - let ctB64 = ct.base64EncodedString() - securePrefs.setValue(ivB64, forKey: "iv-\(key)") - securePrefs.setValue(ctB64, forKey: "ciphertext-\(key)") - return KeystoreResult(ok: true, data: true) - } catch { - return KeystoreResult(ok: false, data: nil, error: String(describing: error)) + /** + * + */ + public func contains_key(_ key: String) -> KeystoreResult { + return accessQueue.sync { + NSLog("🔍 DEBUG: Checking Keychain for key: \(key)") + + let hasKey = keychainExists(forKey: key) + + NSLog("🔒 Key '\(key)' check: \(hasKey)") + + return KeystoreResult(ok: true, data: hasKey) } } - public func retrieve(_ key: String) -> KeystoreResult { - do { - guard let ivB64 = securePrefs.string(forKey: "iv-\(key)"), - let ctB64 = securePrefs.string(forKey: "ciphertext-\(key)"), - let iv = Data(base64Encoded: ivB64), - let ct = Data(base64Encoded: ctB64) - else { - return KeystoreResult(ok: true, data: nil) + public func store(_ key: String, plaintext: String) -> KeystoreResult { + return accessQueue.sync(flags: .barrier) { + NSLog("🔍 Key '\(key)' store begin") + do { + NSLog("🔍 DEBUG: Key '\(key)' store saveToKeychain") + try saveToKeychain(value: plaintext, forKey: key) + + return KeystoreResult(ok: true, data: true) + } catch { + NSLog("❌ ERROR: Key '\(key)' store with error \(String(describing: error))") + return KeystoreResult(ok: false, data: nil, error: String(describing: error)) } - let ctx = LAContext() - ctx.localizedReason = "Unlock to access encryption key" - let encKey = try loadOrCreateSymmetricKey(account: symEncAccount, context: ctx) - guard iv.count == 12 else { - return KeystoreResult(ok: false, data: nil, error: "bad_iv_length") + } + } + + public func retrieve(_ key: String) -> KeystoreResult { + return accessQueue.sync { + NSLog("🔍 Key '\(key)' retrieve begin") + do { + NSLog("🔍 DEBUG: Key '\(key)' retrieveFromKeychain") + let plaintext = try retrieveFromKeychain(forKey: key) + return KeystoreResult(ok: true, data: plaintext) + } catch { + return KeystoreResult(ok: false, data: nil, error: String(describing: error)) } - let nonce = try AES.GCM.Nonce(data: iv) - let ctOnly = ct.prefix(ct.count - 16) - let tag = ct.suffix(16) - let sealed = try AES.GCM.SealedBox(nonce: nonce, ciphertext: ctOnly, tag: tag) - let plaintext = try AES.GCM.open(sealed, using: encKey) - return KeystoreResult(ok: true, data: String(data: plaintext, encoding: .utf8) ?? "") - } catch { - return KeystoreResult(ok: false, data: nil, error: String(describing: error)) } } - public func remove(_ key: String) -> KeystoreResult { - securePrefs.removeObject(forKey: "iv-\(key)") - securePrefs.removeObject(forKey: "ciphertext-\(key)") - plainPrefs.removeObject(forKey: key) - return KeystoreResult(ok: true, data: true) + return accessQueue.sync(flags: .barrier) { + deleteFromKeychain(forKey: key) + plainPrefs.removeObject(forKey: key) + return KeystoreResult(ok: true, data: true) + } } - + public func hmac_sha256(_ message: String) -> KeystoreResult { - do { - let ctx = LAContext() - ctx.localizedReason = "Unlock to access HMAC key" - let key = try loadOrCreateSymmetricKey(account: symHmacAccount, context: ctx) - let tag = HMAC.authenticationCode(for: Data(message.utf8), using: key) - return KeystoreResult(ok: true, data: dataToHex(Data(tag))) - } catch { - return KeystoreResult(ok: false, data: nil, error: String(describing: error)) + return accessQueue.sync { + do { + // Ensure HMAC key exists + try ensureHmacKey() + + // Retrieve the key (this will trigger biometric authentication) + guard let keyBase64 = try retrieveFromKeychain(forKey: hmacKeyAlias), + let keyData = Data(base64Encoded: keyBase64) else { + throw NSError(domain: "KeystoreCore", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to retrieve HMAC key"]) + } + + let key = SymmetricKey(data: keyData) + + // Compute the HMAC + let messageData = Data(message.utf8) + let tag = HMAC.authenticationCode(for: messageData, using: key) + + // Convert to hexadecimal string + let hexString = tag.map { String(format: "%02x", $0) }.joined() + + return KeystoreResult(ok: true, data: hexString) + } catch { + NSLog("❌ ERROR: HMAC computation failed: \(error)") + return KeystoreResult(ok: false, data: nil, error: "Failed to compute HMAC-SHA256: \(error.localizedDescription)") + } } } - + public func shared_secret_pub_key() -> KeystoreResult { - do { - let ctx = LAContext() - ctx.localizedReason = "Unlock to access ECDH key" - let priv = try loadOrCreateECPrivateKey(context: ctx) - let pub = try KeychainHelper.publicKey(for: priv) - let pubData = try KeychainHelper.publicKeyX963Data(for: pub) - return KeystoreResult(ok: true, data: dataToHex(pubData)) - } catch { - return KeystoreResult(ok: false, data: nil, error: String(describing: error)) - } + return KeystoreResult(ok: false, data: nil, error: "Not implement") } - + public func shared_secret(_ pubKeys: [String]) -> KeystoreResult<[String]> { - do { - let ctx = LAContext() - ctx.localizedReason = "Unlock to perform key agreement" - let priv = try loadOrCreateECPrivateKey(context: ctx) - var results: [String] = [] - for hex in pubKeys { - guard let peerX963 = hexToData(hex) else { - return KeystoreResult(ok: false, data: nil, error: "bad_pubkey_hex") - } - let attrs: [String: Any] = [ - kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, - kSecAttrKeyClass as String: kSecAttrKeyClassPublic, - kSecAttrKeySizeInBits as String: 256, - ] - var err: Unmanaged? - guard - let peerKey = SecKeyCreateWithData( - peerX963 as CFData, attrs as CFDictionary, &err) - else { - throw err!.takeRetainedValue() as Error - } - var error: Unmanaged? - guard - let secret = SecKeyCopyKeyExchangeResult( - priv, SecKeyAlgorithm.ecdhKeyExchangeStandard, peerKey, [:] as CFDictionary, - &error) as Data? - else { - throw error!.takeRetainedValue() as Error - } - results.append(dataToHex(secret)) - } - return KeystoreResult(ok: true, data: results) - } catch { - return KeystoreResult(ok: false, data: nil, error: String(describing: error)) + return KeystoreResult(ok: false, data: nil, error: "Not implement") + } + + // MARK: - Keychain Helper Methods + + private func ensureHmacKey() throws { + + // Check if the key already exists + if let _ = try? retrieveFromKeychain(forKey: hmacKeyAlias) { + // Key already exists, nothing to do + return } + + // Create a new key if it doesn't exist + let newKey = SymmetricKey(size: .bits256) + let keyData = newKey.withUnsafeBytes { Data($0) } + let keyBase64 = keyData.base64EncodedString() + + // Store the key in the keychain with biometric protection + // Your existing saveToKeychain method already handles the biometric requirement + try saveToKeychain(value: keyBase64, forKey: hmacKeyAlias) + + NSLog("✅ Created new HMAC key") } - // Internals + /** + * + */ + private func keychainExists(forKey key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainServiceGroupName, + kSecAttrAccount as String: key, + kSecReturnData as String: false, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + let status = SecItemCopyMatching(query as CFDictionary, nil) + return status == errSecSuccess + } - private func loadOrCreateSymmetricKey(account: String, context: LAContext?) throws - -> SymmetricKey - { - if let data = try? KeychainHelper.retrieveGenericPassword( - account: account, context: context) - { - return SymmetricKey(data: data) + /** + * + */ + private func saveToKeychain(value: String, forKey key: String) throws { + guard let data = value.data(using: .utf8) else { + NSLog("💥 Failed to encode string") + throw NSError(domain: "KeystoreCore", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to encode string"]) } - let key = SymmetricKey(size: .bits256) - let data = key.withUnsafeBytes { Data($0) } + + NSLog("🔒 Key '\(key)' store: value: [REDACTED]") + let access = try makeAccessControl(requirePrivateKeyUsage: false) - try KeychainHelper.saveGenericPassword(account: account, data: data, access: access) - return key + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainServiceGroupName, + kSecAttrAccount as String: key, + kSecAttrAccessControl as String: access, + kSecAttrSynchronizable as String: kCFBooleanFalse as Any, + kSecValueData as String: data + ] + + // Delete existing item if present + SecItemDelete(query as CFDictionary) + + NSLog("🔍 DEBUG: Account '\(key)' SecItemAdd") + let status = SecItemAdd(query as CFDictionary, nil) + NSLog("🔍 DEBUG: Account '\(key)' with status: \(status)") + guard status == errSecSuccess else { + NSLog("❌ ERROR: Account '\(key)' with status: \(status)") + if let error = SecCopyErrorMessageString(status, nil) as String? { + NSLog("❌ Error message: \(error)") + } + throw NSError(domain: "KeystoreCore", code: Int(status), userInfo: [NSLocalizedDescriptionKey: "Failed to store to Keychain"]) + } } + + private func retrieveFromKeychain(forKey key: String) throws -> String? { + let context = LAContext() + context.localizedReason = "Access your passkey" + + // You can explicitly enable passcode fallback + context.localizedFallbackTitle = "Use Passcode" // Custom text for passcode button - private func loadOrCreateECPrivateKey(context: LAContext?) throws -> SecKey { - let access = try makeAccessControl(requirePrivateKeyUsage: true) - return try KeychainHelper.createOrLoadSecureEnclavePrivateKey(tag: ecdhTag, access: access) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainServiceGroupName, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecUseAuthenticationContext as String: context + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecItemNotFound { + return nil + } + + guard status == errSecSuccess else { + NSLog("❌ ERROR: Failed to retrieve key '\(key)' with status: \(status)") + if let error = SecCopyErrorMessageString(status, nil) as String? { + NSLog("❌ Error message: \(error)") + } + throw NSError(domain: "KeystoreCore", code: Int(status), userInfo: [NSLocalizedDescriptionKey: "Failed to retrieve from Keychain. Status: \(status)"]) + } + + guard let data = result as? Data else { + NSLog("❌ ERROR: Retrieved item for key '\(key)' but couldn't cast to Data") + throw NSError(domain: "KeystoreCore", code: -2, userInfo: [NSLocalizedDescriptionKey: "Failed to cast result to Data"]) + } + + guard let string = String(data: data, encoding: .utf8) else { + NSLog("❌ ERROR: Retrieved Data for key '\(key)' but couldn't decode as UTF-8 string") + NSLog("❌ DEBUG: Data length: \(data.count) bytes, first few bytes: \(data.prefix(min(10, data.count)).map { String(format: "%02x", $0) }.joined())") + throw NSError(domain: "KeystoreCore", code: -3, userInfo: [NSLocalizedDescriptionKey: "Failed to decode data as UTF-8 string"]) + } + + NSLog("✅ SUCCESS: Retrieved and decoded item for key '\(key)'") + + return string } + /** + * + */ + private func deleteFromKeychain(forKey key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainServiceGroupName, + kSecAttrAccount as String: key + ] + + SecItemDelete(query as CFDictionary) + } + + /** + * Policy for storing value: must require biometric or device passcode or private key if true + */ private func makeAccessControl(requirePrivateKeyUsage: Bool) throws -> SecAccessControl { - var flags: SecAccessControlCreateFlags = [.biometryCurrentSet, .userPresence] + // Use OR to allow multiple authentication methods + var flags: SecAccessControlCreateFlags = [.or] + + // Add biometrics if available + flags.insert(.biometryAny) + + // Also allow device passcode as a fallback + flags.insert(.devicePasscode) + + // Add private key usage if needed if requirePrivateKeyUsage { flags.insert(.privateKeyUsage) } + var error: Unmanaged? guard let ac = SecAccessControlCreateWithFlags( diff --git a/ios/Sources/KeystorePlugin/PluginShim.swift b/ios/Sources/KeystorePlugin/PluginShim.swift index ccfea75..722a1e0 100644 --- a/ios/Sources/KeystorePlugin/PluginShim.swift +++ b/ios/Sources/KeystorePlugin/PluginShim.swift @@ -1,9 +1,6 @@ - import Foundation -#if canImport(Tauri) import Tauri import SwiftRs -#endif class ContainsKey: Decodable { let key: String @@ -24,7 +21,7 @@ class RetrieveUnencrypted: Decodable { class Store: Decodable { let key: String - let plaintext: String + let value: String } class Retrieve: Decodable { @@ -32,68 +29,77 @@ class Retrieve: Decodable { } class Remove: Decodable { - let key: String + let service: String + let user: String } class HmacSha256: Decodable { - let message: String + let input: String } class SharedSecret: Decodable { let withP256PubKeys: [String] } -#if canImport(Tauri) - class KeystorePlugin: Plugin { private let core = KeystoreCore.shared /// contains_key(key: String) -> Bool @objc public func contains_key(_ invoke: Invoke) throws { let args = try invoke.parseArgs(ContainsKey.self) - invoke.resolve(core.contains_key(args.key)) + let response = core.contains_key(args.key) + invoke.resolve(response.data) } /// contains_unencrypted_key(key: String) -> Bool @objc public func contains_unencrypted_key(_ invoke: Invoke) throws { let args = try invoke.parseArgs(ContainsUnencryptedKey.self) - invoke.resolve(core.contains_unencrypted_key(args.key)) + let response = core.contains_unencrypted_key(args.key) + invoke.resolve(response.data) } - /// store_unencrypted(key: String, value: String) -> Bool + /// store_unencrypted(key: String, value: String) -> Bool @objc public func store_unencrypted(_ invoke: Invoke) throws { let args = try invoke.parseArgs(StoreUnencrypted.self) - invoke.resolve(core.store_unencrypted(args.key, value: args.value)) + let response = core.store_unencrypted(args.key, value: args.value) + invoke.resolve() } /// retrieve_unencrypted(key: String) -> String? @objc public func retrieve_unencrypted(_ invoke: Invoke) throws { let args = try invoke.parseArgs(RetrieveUnencrypted.self) - invoke.resolve(core.retrieve_unencrypted(args.key)) + let response = core.retrieve_unencrypted(args.key) + let json = ["value": response.data ?? "null"] + invoke.resolve(json) } /// store(key: String, plaintext: String) -> Bool @objc public func store(_ invoke: Invoke) throws { let args = try invoke.parseArgs(Store.self) - invoke.resolve(core.store(args.key, plaintext: args.plaintext)) + core.store(args.key, plaintext: args.value) + invoke.resolve() } /// retrieve(key: String) -> String? @objc public func retrieve(_ invoke: Invoke) throws { let args = try invoke.parseArgs(Retrieve.self) - invoke.resolve(core.retrieve(args.key)) + let response = core.retrieve(args.key) + let json = ["value": response.data ?? "null"] + invoke.resolve(json) } /// remove(key: String) -> Bool @objc public func remove(_ invoke: Invoke) throws { let args = try invoke.parseArgs(Remove.self) - invoke.resolve(core.remove(args.key)) + invoke.resolve() } /// hmac_sha256(message: String) -> hex String @objc public func hmac_sha256(_ invoke: Invoke) throws { let args = try invoke.parseArgs(HmacSha256.self) - invoke.resolve(core.hmac_sha256(args.message)) + let response = core.hmac_sha256(args.input) + let json = ["output": response.data ?? "null"] + invoke.resolve(json) } /// shared_secret_pub_key() -> hex String (no args) @@ -109,10 +115,6 @@ class KeystorePlugin: Plugin { } } -@_cdecl("init_plugin_keystore") -public func initPluginKeystore() -> UnsafeMutableRawPointer? { - let plugin = KeystorePlugin() - let unmanaged = Unmanaged.passRetained(plugin) - return UnsafeMutableRawPointer(unmanaged.toOpaque()) +@_cdecl("init_plugin_keystore") func initPluginKeystore() -> Plugin { + return KeystorePlugin() } -#endif diff --git a/package.json b/package.json index 325c9b1..efa2f6d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@0x330a/tauri-plugin-keystore-api", - "version": "2.1.0-alpha.5", + "version": "2.1.0-alpha.6", "author": "0x330a", "description": "Interact with the device-native key storage (Android Keystore, iOS Keychain). perform ecdh operations for generating symmetric keys", "type": "module",