Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions source/api/src/main/kotlin/com/clerk/api/storage/StorageCipher.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package com.clerk.api.storage

import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import com.clerk.api.Constants.Storage.CLERK_PREFERENCES_FILE_NAME
import java.security.KeyStore
import java.security.MessageDigest
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec

internal interface StorageCipher {
fun encrypt(plaintext: String): String

fun decrypt(ciphertext: String): String
}

internal object StorageCipherFactory {
fun create(keyAlias: String = "$CLERK_PREFERENCES_FILE_NAME.master_key"): StorageCipher {
return try {
AndroidKeystoreStorageCipher(keyAlias)
} catch (error: Exception) {
if (isRobolectricEnvironment()) {
JvmAesGcmStorageCipher(keyAlias)
} else {
throw error
}
}
}

private fun isRobolectricEnvironment(): Boolean {
return Build.FINGERPRINT.contains("robolectric", ignoreCase = true)
}
}

internal class AndroidKeystoreStorageCipher(
private val keyAlias: String = "$CLERK_PREFERENCES_FILE_NAME.master_key"
) : StorageCipher {
@Volatile private var cachedSecretKey: SecretKey? = null
private val keyStore: KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }

override fun encrypt(plaintext: String): String {
val cipher = Cipher.getInstance(AES_TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateSecretKey())
val encrypted = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
return "${encode(cipher.iv)}$IV_SEPARATOR${encode(encrypted)}"
}

override fun decrypt(ciphertext: String): String {
val components = ciphertext.split(IV_SEPARATOR, limit = 2)
require(components.size == 2) { "Encrypted value is malformed" }

val iv = decode(components[0])
val encrypted = decode(components[1])
val cipher = Cipher.getInstance(AES_TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, getOrCreateSecretKey(), GCMParameterSpec(GCM_TAG_BITS, iv))
return cipher.doFinal(encrypted).toString(Charsets.UTF_8)
}

private fun getOrCreateSecretKey(): SecretKey {
cachedSecretKey?.let {
return it
}

return synchronized(this) {
cachedSecretKey?.let {
return@synchronized it
}

val existingKey = keyStore.getKey(keyAlias, null) as? SecretKey
if (existingKey != null) {
cachedSecretKey = existingKey
return@synchronized existingKey
}

val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
val keySpec =
KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(true)
.setUserAuthenticationRequired(false)
.build()

keyGenerator.init(keySpec)
return@synchronized keyGenerator.generateKey().also { generatedKey ->
cachedSecretKey = generatedKey
}
}
}

private fun encode(bytes: ByteArray): String = Base64.encodeToString(bytes, Base64.NO_WRAP)

private fun decode(encoded: String): ByteArray = Base64.decode(encoded, Base64.NO_WRAP)

private companion object {
const val ANDROID_KEYSTORE = "AndroidKeyStore"
const val AES_TRANSFORMATION = "AES/GCM/NoPadding"
const val GCM_TAG_BITS = 128
const val IV_SEPARATOR = ':'
}
}

internal class JvmAesGcmStorageCipher(private val keyAlias: String) : StorageCipher {
private val secretKey: SecretKey by lazy {
val keyBytes = MessageDigest.getInstance("SHA-256").digest(keyAlias.toByteArray(Charsets.UTF_8))
SecretKeySpec(keyBytes.copyOf(16), KeyProperties.KEY_ALGORITHM_AES)
}

override fun encrypt(plaintext: String): String {
val cipher = Cipher.getInstance(AES_TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
val encrypted = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
return "${encode(cipher.iv)}$IV_SEPARATOR${encode(encrypted)}"
}

override fun decrypt(ciphertext: String): String {
val components = ciphertext.split(IV_SEPARATOR, limit = 2)
require(components.size == 2) { "Encrypted value is malformed" }

val iv = decode(components[0])
val encrypted = decode(components[1])
val cipher = Cipher.getInstance(AES_TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(GCM_TAG_BITS, iv))
return cipher.doFinal(encrypted).toString(Charsets.UTF_8)
}

private fun encode(bytes: ByteArray): String = Base64.encodeToString(bytes, Base64.NO_WRAP)

private fun decode(encoded: String): ByteArray = Base64.decode(encoded, Base64.NO_WRAP)

private companion object {
const val AES_TRANSFORMATION = "AES/GCM/NoPadding"
const val GCM_TAG_BITS = 128
const val IV_SEPARATOR = ':'
}
}
104 changes: 86 additions & 18 deletions source/api/src/main/kotlin/com/clerk/api/storage/StorageHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,46 +8,98 @@ import com.clerk.api.Constants.Storage.CLERK_PREFERENCES_FILE_NAME
import com.clerk.api.log.ClerkLog

/**
* Helper class to manage secure storage of data. SharedPreferences are used to store data, all keys
* are held in the [StorageKey] object.
* Helper class to manage secure storage of data. SharedPreferences are used as the persistence
* backend, while values are encrypted before they are written to disk.
*/
internal object StorageHelper {
private const val ENCRYPTED_VALUE_PREFIX = "clerk:v1:"

@Volatile private var secureStorage: SharedPreferences? = null
@Volatile private var storageCipher: StorageCipher? = null

@VisibleForTesting internal var storageCipherFactoryOverride: (() -> StorageCipher)? = null

/**
* Synchronously initializes the secure storage. We do this synchronously because we need to
* ensure that the storage is initialized before we generate a device ID.
*/
@Synchronized
fun initialize(context: Context) {
secureStorage = context.getSharedPreferences(CLERK_PREFERENCES_FILE_NAME, Context.MODE_PRIVATE)
if (secureStorage == null) {
secureStorage =
context.applicationContext.getSharedPreferences(
CLERK_PREFERENCES_FILE_NAME,
Context.MODE_PRIVATE,
)
}
if (storageCipher == null) {
storageCipher =
runCatching { storageCipherFactoryOverride?.invoke() ?: StorageCipherFactory.create() }
.onFailure { error ->
ClerkLog.w("Failed to initialize encrypted storage: ${error.message}")
}
.getOrNull()
}
}

/** Save value of string type to [secureStorage] */
internal fun saveValue(key: StorageKey, value: String) {
val prefs = secureStorage
if (prefs == null) {
ClerkLog.w(
"StorageHelper.saveValue called before initialization, ignoring save for key: ${key.name}"
)
return
}
if (value.isNotEmpty()) {
prefs.edit(commit = true) { putString(key.name, value) }
return
val cipher = storageCipher

when {
prefs == null -> {
ClerkLog.w(
"StorageHelper.saveValue called before initialization, ignoring save for key: ${key.name}"
)
}
value.isEmpty() -> Unit
cipher == null -> {
ClerkLog.w("Encrypted storage is unavailable, ignoring save for key: ${key.name}")
}
else -> {
runCatching { ENCRYPTED_VALUE_PREFIX + cipher.encrypt(value) }
.onSuccess { encryptedValue ->
prefs.edit(commit = true) { putString(key.name, encryptedValue) }
}
.onFailure { error ->
ClerkLog.w("Failed to encrypt value for key ${key.name}: ${error.message}")
}
}
}
}

/** Load value of string type from [secureStorage] */
internal fun loadValue(key: StorageKey): String? {
val prefs = secureStorage
if (prefs == null) {
ClerkLog.w(
"StorageHelper.loadValue called before initialization, returning null for key: ${key.name}"
)
return null
val storedValue = prefs?.getString(key.name, null)
val cipher = storageCipher

return when {
prefs == null -> {
ClerkLog.w(
"StorageHelper.loadValue called before initialization, returning null for key: ${key.name}"
)
null
}
storedValue == null -> null
!storedValue.startsWith(ENCRYPTED_VALUE_PREFIX) -> {
migrateLegacyPlaintextValue(key, storedValue)
storedValue
}
cipher == null -> {
ClerkLog.w("Encrypted storage is unavailable, returning null for key: ${key.name}")
null
}
else -> {
runCatching { cipher.decrypt(storedValue.removePrefix(ENCRYPTED_VALUE_PREFIX)) }
.onFailure { error ->
ClerkLog.w("Failed to decrypt stored value for key ${key.name}: ${error.message}")
prefs.edit(commit = true) { remove(key.name) }
}
.getOrNull()
}
}
return prefs.getString(key.name, null)
}

/** Delete value of string type from [secureStorage] */
Expand All @@ -73,6 +125,7 @@ internal object StorageHelper {
if (prefs != null) {
prefs.edit().clear().commit()
}
storageCipher = null
if (context != null) {
// Reinitialize to ensure clean state
initialize(context)
Expand All @@ -81,6 +134,21 @@ internal object StorageHelper {
secureStorage = null
}
}

private fun migrateLegacyPlaintextValue(key: StorageKey, value: String) {
if (value.isEmpty()) {
return
}

val cipher = storageCipher ?: return
runCatching { ENCRYPTED_VALUE_PREFIX + cipher.encrypt(value) }
.onSuccess { encryptedValue ->
secureStorage?.edit(commit = true) { putString(key.name, encryptedValue) }
}
.onFailure { error ->
ClerkLog.w("Failed to migrate plaintext value for key ${key.name}: ${error.message}")
}
}
}

internal enum class StorageKey {
Expand Down
Loading