From 4c4e6c99a03d159411a930a19b98f2bd54c9b10a Mon Sep 17 00:00:00 2001 From: Jakub Szerszen Date: Fri, 7 Jun 2024 15:57:42 -0700 Subject: [PATCH] Camv 527 port key manager to sprucekit sdk kotlin (#13) * Added key manager and supporting test --- .idea/deploymentTargetSelector.xml | 19 ++ WalletSdk/build.gradle.kts | 4 +- .../walletsdk/ExampleInstrumentedTest.kt | 24 -- .../com/spruceid/wallet/sdk/KeyManager.kt | 314 ++++++++++++++++++ .../wallet/sdk/KeyManagerEnvironment.kt | 9 + .../com/spruceid/wallet/sdk/KeyManagerTest.kt | 38 +++ example/build.gradle.kts | 1 - 7 files changed, 383 insertions(+), 26 deletions(-) create mode 100644 .idea/deploymentTargetSelector.xml delete mode 100644 WalletSdk/src/androidTest/java/com/spruceid/walletsdk/ExampleInstrumentedTest.kt create mode 100644 WalletSdk/src/main/java/com/spruceid/wallet/sdk/KeyManager.kt create mode 100644 WalletSdk/src/main/java/com/spruceid/wallet/sdk/KeyManagerEnvironment.kt create mode 100644 WalletSdk/src/test/java/com/spruceid/wallet/sdk/KeyManagerTest.kt diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..825f058 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WalletSdk/build.gradle.kts b/WalletSdk/build.gradle.kts index f9d3630..af1dc82 100644 --- a/WalletSdk/build.gradle.kts +++ b/WalletSdk/build.gradle.kts @@ -76,7 +76,7 @@ nmcp { android { - namespace = "com.spruceid.walletsdk" + namespace = "com.spruceid.wallet.sdk" compileSdk = 33 defaultConfig { @@ -128,8 +128,10 @@ dependencies { implementation("androidx.camera:camera-view:1.3.2") implementation("com.google.zxing:core:3.3.3") implementation("com.google.accompanist:accompanist-permissions:0.34.0") + implementation("androidx.test.ext:junit-ktx:1.1.5") /* End UI dependencies */ testImplementation("junit:junit:4.13.2") + androidTestImplementation("com.android.support.test:runner:1.0.2") androidTestImplementation("com.android.support.test.espresso:espresso-core:3.0.2") } diff --git a/WalletSdk/src/androidTest/java/com/spruceid/walletsdk/ExampleInstrumentedTest.kt b/WalletSdk/src/androidTest/java/com/spruceid/walletsdk/ExampleInstrumentedTest.kt deleted file mode 100644 index e4a5f6f..0000000 --- a/WalletSdk/src/androidTest/java/com/spruceid/walletsdk/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.spruceid.walletsdk - -import androidx.test.InstrumentationRegistry -import androidx.test.runner.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.spruceid.wallet.sdk.test", appContext.packageName) - } -} \ No newline at end of file diff --git a/WalletSdk/src/main/java/com/spruceid/wallet/sdk/KeyManager.kt b/WalletSdk/src/main/java/com/spruceid/wallet/sdk/KeyManager.kt new file mode 100644 index 0000000..d71afc7 --- /dev/null +++ b/WalletSdk/src/main/java/com/spruceid/wallet/sdk/KeyManager.kt @@ -0,0 +1,314 @@ +package com.spruceid.wallet.sdk + +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import android.util.Log +import androidx.annotation.RequiresApi +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.Signature +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * Implementation of the secure key management with Strongbox and TEE as backup. + */ +class KeyManager { + + /** + * Returns the Android Keystore. + * @return instance of the key store. + */ + private fun getKeyStore(): KeyStore { + return KeyStore.getInstance("AndroidKeyStore").apply { + load(null) + } + } + + /** + * Returns a secret key - based on the id of the key. + * @property id of the secret key. + * @return the public secret key interface object. + */ + private fun getSecretKey(id: String): SecretKey? { + val ks = getKeyStore() + + val entry: KeyStore.Entry = ks.getEntry(id, null) + if (entry !is KeyStore.SecretKeyEntry) { + Log.w("KEYMAN", "Not an instance of a SecretKeyEntry") + return null + } + + return entry.secretKey + } + + /** + * Resets the Keystore by removing all of the keys. + */ + fun reset() { + val ks = getKeyStore() + ks.aliases().iterator().forEach { + ks.deleteEntry(it) + } + } + + /** + * Generates a secp256r1 signing key by id/alias in the Keystore with Strongbox when + * min SDK and hardware requirements are met, otherwise using TEE. + * @property id of the secret key. + * @returns KeyManagerEnvironment indicating the environment used to generate the key. + */ + fun generateSigningKey(id: String): KeyManagerEnvironment { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + generateSigningKeyWithStrongbox(id) + + return KeyManagerEnvironment.Strongbox + } else { + generateSigningKeyTEE(id) + + return KeyManagerEnvironment.TEE + } + } catch (e: Exception) { + generateSigningKeyTEE(id) + + return KeyManagerEnvironment.TEE + } + } + + /** + * Generates a secp256r1 signing key by id/alias in the Keystore with Strongbox. + * @property id of the secret key. + */ + @RequiresApi(Build.VERSION_CODES.P) + private fun generateSigningKeyWithStrongbox(id: String) { + val generator = KeyPairGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_EC, + "AndroidKeyStore", + ) + + val spec = KeyGenParameterSpec.Builder( + id, + KeyProperties.PURPOSE_SIGN + or KeyProperties.PURPOSE_VERIFY, + ) + .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) + .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) + .setIsStrongBoxBacked(true) + .build() + + generator.initialize(spec) + generator.generateKeyPair() + } + + /** + * Generates a secp256r1 signing key by id/alias in the Keystore with TEE. + * @property id of the secret key. + */ + private fun generateSigningKeyTEE(id: String) { + val generator = KeyPairGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_EC, + "AndroidKeyStore", + ) + + val spec = KeyGenParameterSpec.Builder( + id, + KeyProperties.PURPOSE_SIGN + or KeyProperties.PURPOSE_VERIFY, + ) + .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) + .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) + .build() + + generator.initialize(spec) + generator.generateKeyPair() + } + + /** + * Assumes the value above 32 will always be 33. + * BigInteger will add an extra byte to keep the number positive. + * But the key values will always be 32 bytes. + * @property input byte array to be processed. + * @return byte array with 32 bytes. + */ + fun clampOrFill(input: ByteArray): ByteArray { + return if (input.size > 32) { + input.drop(1).toByteArray() + } else if (input.size < 32) { + List(32 - input.size){ 0.toByte() }.toByteArray() + input + } else { + input + } + } + + /** + * Returns a JWK for a particular secret key by key id. + * @property id of the secret key. + * @return the JWK as a string. + */ + fun getJwk(id: String): String? { + val ks = getKeyStore() + val key = ks.getEntry(id, null) + + if (key is KeyStore.PrivateKeyEntry) { + if (key.certificate.publicKey is ECPublicKey) { + val ecPublicKey = key.certificate.publicKey as ECPublicKey + val x = Base64.encodeToString( + clampOrFill(ecPublicKey.w.affineX.toByteArray()), + Base64.URL_SAFE + xor Base64.NO_PADDING + xor Base64.NO_WRAP + ) + val y = Base64.encodeToString( + clampOrFill(ecPublicKey.w.affineY.toByteArray()), + Base64.URL_SAFE + xor Base64.NO_PADDING + xor Base64.NO_WRAP + ) + + return """{"kty":"EC","crv":"P-256","x":"$x","y":"$y"}""" + } + } + + return null + } + + /** + * Checks to see if a key already exists. + * @property id of the secret key. + * @return indication if the key exists. + */ + fun keyExists(id: String): Boolean { + val ks = getKeyStore() + return ks.containsAlias(id) && ks.isKeyEntry(id) + } + + /** + * Signs the provided payload with a SHA256withECDSA private key. + * @property id of the secret key. + * @property payload to be signed. + * @return the signed payload. + */ + fun signPayload(id: String, payload: ByteArray): ByteArray? { + val ks = getKeyStore() + val entry: KeyStore.Entry = ks.getEntry(id, null) + if (entry !is KeyStore.PrivateKeyEntry) { + Log.w("KEYMAN", "Not an instance of a PrivateKeyEntry") + return null + } + + return Signature.getInstance("SHA256withECDSA").run { + initSign(entry.privateKey) + update(payload) + sign() + } + } + + /** + * Generates an AES encryption key with a provided id in the Keystore. + * @property id of the secret key. + * @returns KeyManagerEnvironment indicating the environment used to generate the key. + */ + fun generateEncryptionKey(id: String): KeyManagerEnvironment { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + generateEncryptionKeyWithStrongbox(id) + + return KeyManagerEnvironment.Strongbox + } else { + generateEncryptionKeyWithTEE(id) + + return KeyManagerEnvironment.TEE + } + } catch (e: Exception) { + generateEncryptionKeyWithTEE(id) + + return KeyManagerEnvironment.TEE + } + } + + /** + * Generates an AES encryption key with a provided id in the Keystore. + * @property id of the secret key. + */ + @RequiresApi(Build.VERSION_CODES.P) + private fun generateEncryptionKeyWithStrongbox(id: String) { + val generator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + "AndroidKeyStore", + ) + + val spec = KeyGenParameterSpec.Builder( + id, + KeyProperties.PURPOSE_ENCRYPT + or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setIsStrongBoxBacked(true) + .build() + + generator.init(spec) + generator.generateKey() + } + + /** + * Generates an AES encryption key with a provided id in the Keystore. + * @property id of the secret key. + */ + private fun generateEncryptionKeyWithTEE(id: String) { + val generator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + "AndroidKeyStore", + ) + + val spec = KeyGenParameterSpec.Builder( + id, + KeyProperties.PURPOSE_ENCRYPT + or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build() + + generator.init(spec) + generator.generateKey() + } + + /** + * Encrypts payload by a key referenced by key id. + * @property id of the secret key. + * @property payload to be encrypted. + * @return initialization vector with the encrypted payload. + */ + fun encryptPayload(id: String, payload: ByteArray): Pair { + val secretKey = getSecretKey(id) + + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, secretKey) + val iv = cipher.iv + val encrypted = cipher.doFinal(payload) + return Pair(iv, encrypted) + } + + /** + * Decrypts the provided payload by a key id and initialization vector. + * @property id of the secret key. + * @property iv initialization vector. + * @property payload to be encrypted. + * @return the decrypted payload. + */ + fun decryptPayload(id: String, iv: ByteArray, payload: ByteArray): ByteArray? { + val secretKey = getSecretKey(id) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val spec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) + return cipher.doFinal(payload) + } +} diff --git a/WalletSdk/src/main/java/com/spruceid/wallet/sdk/KeyManagerEnvironment.kt b/WalletSdk/src/main/java/com/spruceid/wallet/sdk/KeyManagerEnvironment.kt new file mode 100644 index 0000000..50dbd93 --- /dev/null +++ b/WalletSdk/src/main/java/com/spruceid/wallet/sdk/KeyManagerEnvironment.kt @@ -0,0 +1,9 @@ +package com.spruceid.wallet.sdk + +/** + * The Keystore environment used for the key generation. + */ +enum class KeyManagerEnvironment(val string: String) { + TEE("tee"), + Strongbox("strongbox"), +} \ No newline at end of file diff --git a/WalletSdk/src/test/java/com/spruceid/wallet/sdk/KeyManagerTest.kt b/WalletSdk/src/test/java/com/spruceid/wallet/sdk/KeyManagerTest.kt new file mode 100644 index 0000000..0d483eb --- /dev/null +++ b/WalletSdk/src/test/java/com/spruceid/wallet/sdk/KeyManagerTest.kt @@ -0,0 +1,38 @@ +package com.spruceid.wallet.sdk + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Tests for KeyManager supporting functions. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class KeyManagerTest { + + @Test + fun clampOrFill() { + val keyManager = KeyManager() + + // Greater than 32 + val inputMoreThan = ByteArray(33) { it.toByte() } + val expectedMoreThan = inputMoreThan.drop(1).toByteArray() + val resultMoreThan = keyManager.clampOrFill(inputMoreThan) + + assertArrayEquals(expectedMoreThan, resultMoreThan) + + // Less than 32. + val inputLessThan = ByteArray(30) { it.toByte() } + val expectedLessThan = ByteArray(2) { 0.toByte() } + inputLessThan + val result = keyManager.clampOrFill(inputLessThan) + + assertArrayEquals(expectedLessThan, result) + + // Equal to 32. + val inputEqualTo = ByteArray(32) { it.toByte() } + val resultEqualTo = keyManager.clampOrFill(inputEqualTo) + + assertArrayEquals(inputEqualTo, resultEqualTo) + } +} diff --git a/example/build.gradle.kts b/example/build.gradle.kts index c4e3eb3..cdf423b 100644 --- a/example/build.gradle.kts +++ b/example/build.gradle.kts @@ -50,7 +50,6 @@ android { } dependencies { - implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") implementation("androidx.activity:activity-compose:1.8.2")