diff --git a/Cargo.toml b/Cargo.toml index 7a8314e..6aa288f 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.6" authors = ["daniel-mader"] description = "Interact with the device-native key storage (Android Keystore, iOS Keychain)." edition = "2021" @@ -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/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 e5d4049..2228e7b 100644 --- a/android/src/main/java/KeystorePlugin.kt +++ b/android/src/main/java/KeystorePlugin.kt @@ -3,47 +3,120 @@ 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 android.webkit.WebView import androidx.biometric.BiometricPrompt -import java.security.KeyStore +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 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 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 javax.crypto.Cipher -import javax.crypto.SecretKey +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.* 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 KEY_HMAC_ALIAS = "key_hmac_alias" private const val ANDROID_KEYSTORE = "AndroidKeyStore" private const val SHARED_PREFERENCES_NAME = "secure_storage" +class KeystoreConfig @JvmOverloads constructor(val unencryptedStoreName: String = "unencrypted") + @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 +class SharedSecretRequest { + lateinit var withP256PubKeys: List +} + +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) { - 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 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) + 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) { @@ -55,88 +128,64 @@ 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) - - // 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(Charset.forName("UTF-8"))) - val iv = authCipher.iv // Capture the initialization vector. + 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") + } - // Store the ciphertext and IV. - storeCiphertext(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) + invoke.resolve() + } else { + // Wrap the Cipher in a CryptoObject. + val cryptoObject = BiometricPrompt.CryptoObject(cipher) + + // 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") + + 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") - } + 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 Store Secret") - .setSubtitle("Biometric authentication is required") - .setNegativeButtonText("Cancel") - .build() - - 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") -// } -// } -// ) + 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. @@ -152,7 +201,8 @@ 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() keyGenerator.init(keyGenParameterSpec) @@ -160,40 +210,257 @@ 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() { + 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, + // 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")) + .setDigests(KeyProperties.DIGEST_SHA256) + .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(!BuildConfig.DEBUG) + .setInvalidatedByBiometricEnrollment(false) + .setUserAuthenticationValidityDurationSeconds(-1) + .build() + keyGenerator.init(parameterSpec) + keyGenerator.generateKey() + } + } + + @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 + ensureP256Key() + val signature = getSignature() + Logger.debug("initiated cryptoObject") + + fun performSharedSecret() { + // 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 = "")) + } + + 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 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() + + try { + biometricPrompt.authenticate(promptInfo) + } catch (e: Error) { + Logger.error("couldn't start biometric?: ${e.message}") + } + } + } + + 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 + } + + 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("SHA256withECDSA") + Logger.debug("initializing signing...") + sig.initSign(secretKey as PrivateKey) + Logger.debug("initialized signing!") + 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) } - // ######## 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 } // 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.apply() + prefs.edit { + putString("iv-$key", ivEncoded) + putString("ciphertext-$key", ctEncoded) + } } @Command fun retrieve(invoke: Invoke) { val args = invoke.parseArgs(RetrieveRequest::class.java) - val cipherData = readCipherData() + val cipherData = readCipherData(args.key) if (cipherData == null) { - invoke.reject("No cipher data found in SharedPreferences", "001") + invoke.resolve(JSObject("{value: null}")) return } @@ -206,54 +473,66 @@ 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, Charset.forName("UTF-8")) - - val ret = JSObject() - ret.put("value", cleartext) - invoke.resolve(ret) - } catch (e: Exception) { - invoke.reject("Decryption failed: ${e.message}") + // 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 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() - - // Launch the biometric prompt. - biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher)) + + 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") + } + }) + + // 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)) + } } // 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 } @@ -266,19 +545,43 @@ 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 } @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}") } } -} + + @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 01645f9..99db55c 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,15 @@ -const COMMANDS: &[&str] = &["remove", "retrieve", "store"]; +const COMMANDS: &[&str] = &[ + "remove", + "retrieve", + "store", + "contains_unencrypted_key", + "contains_key", + "shared_secret", + "shared_secret_pub_key", + "store_unencrypted", + "retrieve_unencrypted", + "hmac_sha256" +]; fn main() { tauri_plugin::Builder::new(COMMANDS) diff --git a/guest-js/index.ts b/guest-js/index.ts index 0a76369..7e65b5f 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -1,30 +1,86 @@ -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, - }, - }); +export async function storePlaintext(key: string, value: string): Promise { + return await invoke("plugin:keystore|store_unencrypted", { + payload: { + key, + value, + }, + }); } -export async function retrieve( - service: string, - user: string -): Promise { - return await invoke<{ value?: string }>("plugin:keystore|retrieve", { - payload: { - service, - user, - }, - }).then((r) => (r.value ? r.value : null)); +export async function store(key: string, value: string): Promise { + return await invoke("plugin:keystore|store", { + payload: { + key, + value, + }, + }); +} + +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 + } + }) +} + +export async function retrieve(key: string): Promise { + return await invoke<{ value?: string }>("plugin:keystore|retrieve", { + payload: { + key + }, + }).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(pubKeysHex: string[], salt: string, extraInfo?: string): Promise<{ + chacha20Keys: string[] +} | null> { + return await invoke<{ chacha20Keys: string[] }>("plugin:keystore|shared_secret", { + payload: { + withP256PubKeys: pubKeysHex, + 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 diff --git a/ios/Package.swift b/ios/Package.swift index 217b62e..34a68c1 100644 --- a/ios/Package.swift +++ b/ios/Package.swift @@ -1,32 +1,30 @@ -// swift-tools-version:5.3 -// The swift-tools-version declares the minimum version of Swift required to build this package. - +// swift-tools-version:5.9 import PackageDescription let package = Package( name: "tauri-plugin-keystore", platforms: [ - .macOS(.v10_13), - .iOS(.v13), + .iOS(.v15) ], 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") + .package(name: "Tauri", path: "../.tauri/tauri-api") ], 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") + path: "Sources/KeystorePlugin"), + .testTarget( + name: "KeystorePluginTests", + dependencies: ["tauri-plugin-keystore"], + 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 || status == errSecItemNotFound else { + throw KeychainError.unhandledOSStatus(status) + } + } + + static func createOrLoadSecureEnclavePrivateKey(tag: Data, access: SecAccessControl) throws -> SecKey { + 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..d2a5493 --- /dev/null +++ b/ios/Sources/KeystorePlugin/KeystoreCore.swift @@ -0,0 +1,300 @@ +import CryptoKit +import Foundation +import LocalAuthentication +import Security + +public struct KeystoreResult: Encodable { + public let ok: Bool + public let data: T? + public let error: String? + public init(ok: Bool, data: T? = nil, error: String? = nil) { + self.ok = ok + self.data = data + self.error = error + } +} + +@available(iOS 15, *) +public final class KeystoreCore { + 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 keychainServiceGroupName = "app.metasig.keystore.encrypted" + let hmacKeyAlias = "app.metasig.hmac.key" + + private init() {} + + /** + * + */ + 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 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 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)) + } + } + } + + 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)) + } + } + } + public func remove(_ key: String) -> KeystoreResult { + 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 { + 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 { + return KeystoreResult(ok: false, data: nil, error: "Not implement") + } + + public func shared_secret(_ pubKeys: [String]) -> KeystoreResult<[String]> { + 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") + } + + /** + * + */ + 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 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"]) + } + + NSLog("🔒 Key '\(key)' store: value: [REDACTED]") + + let access = try makeAccessControl(requirePrivateKeyUsage: false) + + 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 + + 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 { + // 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( + 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..722a1e0 --- /dev/null +++ b/ios/Sources/KeystorePlugin/PluginShim.swift @@ -0,0 +1,120 @@ +import Foundation +import Tauri +import SwiftRs + +class ContainsKey: Decodable { + let key: String +} + +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 value: String +} + +class Retrieve: Decodable { + let key: String +} + +class Remove: Decodable { + let service: String + let user: String +} + +class HmacSha256: Decodable { + let input: String +} + +class SharedSecret: Decodable { + let withP256PubKeys: [String] +} + +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) + 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) + let response = core.contains_unencrypted_key(args.key) + invoke.resolve(response.data) + } + + /// store_unencrypted(key: String, value: String) -> Bool + @objc public func store_unencrypted(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(StoreUnencrypted.self) + 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) + 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) + 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) + 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() + } + + /// hmac_sha256(message: String) -> hex String + @objc public func hmac_sha256(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(HmacSha256.self) + let response = core.hmac_sha256(args.input) + let json = ["output": response.data ?? "null"] + invoke.resolve(json) + } + + /// 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 { + let args = try invoke.parseArgs(SharedSecret.self) + invoke.resolve(core.shared_secret(args.withP256PubKeys)) + } +} + +@_cdecl("init_plugin_keystore") func initPluginKeystore() -> Plugin { + return KeystorePlugin() +} diff --git a/ios/Tests/KeystorePluginTests/KeystorePluginTests.swift b/ios/Tests/KeystorePluginTests/KeystorePluginTests.swift new file mode 100644 index 0000000..2890765 --- /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.data, "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() - } -} diff --git a/package.json b/package.json index 0b2fd32..ecf05fc 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "@metasig/tauri-plugin-keystore", - "version": "2.1.0-alpha.1", - "author": "daniel-mader", - "description": "Interact with the device-native key storage (Android Keystore, iOS Keychain).", + "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", "types": "./dist-js/index.d.ts", "main": "./dist-js/index.cjs", @@ -22,17 +22,18 @@ "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": { "type": "git", - "url": "git+https://github.com/Metasig/tauri-plugin-keystore.git" + "url": "git+https://github.com/0x330a-public/tauri-plugin-keystore.git" } } 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/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/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/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/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/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 d696b62..082b8c5 100644 --- a/permissions/autogenerated/reference.md +++ b/permissions/autogenerated/reference.md @@ -2,9 +2,18 @@ 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` +- `allow-shared-secret-pub-key` +- `allow-store-unencrypted` +- `allow-retrieve-unencrypted` +- `allow-hmac-sha256` ## Permission Table @@ -15,6 +24,84 @@ 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. + + + + + + + +`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. + + + + @@ -70,6 +157,84 @@ 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` + + + + +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-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` @@ -90,6 +255,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 506258c..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-retrieve", "allow-store"] +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 e89561d..8fdfeb2 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" @@ -294,40 +294,131 @@ "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 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", - "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 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", + "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 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", - "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": "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", + "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" + "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`\n- `allow-hmac-sha256`" } ] } 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) diff --git a/src/commands.rs b/src/commands.rs index b38e52b..3df6795 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,8 +1,19 @@ -use tauri::{command, AppHandle, Runtime}; +use hex::ToHex; +use hkdf::Hkdf; +use sha2::{Digest, Sha512}; +use tauri::{command, App, 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, @@ -11,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, @@ -19,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, @@ -26,3 +61,56 @@ pub(crate) async fn remove( ) -> crate::Result<()> { 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, + payload: SharedSecretRequest +) -> crate::Result { + + // 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)?; + + // 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 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()) + })?; + + chacha_20_keys.push(okm.encode_hex()); + } + + Ok(ChaChaSharedSecret { + 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/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..13f35e2 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; @@ -22,21 +23,34 @@ pub trait KeystoreExt { fn keystore(&self) -> &Keystore; } -impl> crate::KeystoreExt for T { +impl> KeystoreExt for T { fn keystore(&self) -> &Keystore { self.state::>().inner() } } +#[derive(Serialize, Deserialize, Default)] +#[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::contains_key, + commands::contains_unencrypted_key, commands::remove, commands::retrieve, - commands::store + commands::store, + commands::shared_secret, + commands::shared_secret_pub_key, + commands::hmac_sha256 ]) - .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 0e8c3bd..73cb47a 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -25,21 +25,65 @@ 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) .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) .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) + } + + pub fn shared_secret_pub_key(&self) -> crate::Result { + self.0 + .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 42b1d21..d578888 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,27 +1,87 @@ use serde::{Deserialize, Serialize}; +/// Request to store a value in the keystore #[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, } +/// Request to retrieve a value from the keystore #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct RetrieveRequest { - pub service: String, - pub user: String, + /// The key to retrieve by + pub key: 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 { - pub service: String, - pub user: String, + /// The key to remove + pub key: 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 { + /// 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 { + /// 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 { + /// 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