From 551f59ef3d2409beb8944e3ae2a60a2b63fe2f3f Mon Sep 17 00:00:00 2001 From: Matt Hamann Date: Tue, 18 Nov 2025 12:18:18 -0500 Subject: [PATCH 1/8] fix(deps): support android 16kb page size + dep updates --- android/build.gradle | 11 +- .../java/io/rownd/android/util/Encryption.kt | 113 ++++++++++++------ 2 files changed, 82 insertions(+), 42 deletions(-) diff --git a/android/build.gradle b/android/build.gradle index 7ef58be..5387578 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -71,7 +71,7 @@ android { dependencies { def lifecycle_version = '2.8.3' - def dagger_version = '2.56' + def dagger_version = '2.57.2' def coroutines_version = "1.3.9" def ktor_version = "3.1.1" @@ -86,7 +86,7 @@ dependencies { implementation 'io.rownd:telemetry:1.0.1' - implementation 'androidx.datastore:datastore-preferences:1.1.3' + implementation 'androidx.datastore:datastore-preferences:1.1.7' implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'androidx.recyclerview:recyclerview:1.3.2' @@ -131,15 +131,14 @@ dependencies { implementation 'com.auth0.android:jwtdecode:2.0.2' implementation 'com.lyft.kronos:kronos-android:0.0.1-alpha11' - implementation "com.goterl:lazysodium-android:5.0.2@aar" - implementation "net.java.dev.jna:jna:5.8.0@aar" - implementation "androidx.security:security-crypto:1.0.0" + implementation "com.goterl:lazysodium-android:5.2.0@aar" + implementation "net.java.dev.jna:jna:5.18.1@aar" // Passkeys & Google sign-in implementation "androidx.credentials:credentials:1.5.0" implementation "androidx.credentials:credentials-play-services-auth:1.5.0" implementation 'com.google.android.libraries.identity.googleid:googleid:1.1.1' - implementation 'com.google.android.gms:play-services-auth:21.3.0' + implementation 'com.google.android.gms:play-services-auth:21.4.0' // Dependency injection via Dagger implementation "com.google.dagger:dagger:$dagger_version" diff --git a/android/src/main/java/io/rownd/android/util/Encryption.kt b/android/src/main/java/io/rownd/android/util/Encryption.kt index 3400a82..0f6a565 100644 --- a/android/src/main/java/io/rownd/android/util/Encryption.kt +++ b/android/src/main/java/io/rownd/android/util/Encryption.kt @@ -1,9 +1,9 @@ package io.rownd.android.util import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties import android.util.Log -import androidx.security.crypto.EncryptedFile -import androidx.security.crypto.MasterKeys import com.goterl.lazysodium.LazySodiumAndroid import com.goterl.lazysodium.SodiumAndroid import com.goterl.lazysodium.exceptions.SodiumException @@ -11,46 +11,78 @@ import com.goterl.lazysodium.interfaces.SecretBox import com.goterl.lazysodium.utils.Base64MessageEncoder import com.goterl.lazysodium.utils.Key import io.rownd.android.Rownd -import java.io.ByteArrayOutputStream -import java.io.File -import java.io.IOException +import java.io.* +import java.security.KeyStore import java.util.* +import javax.crypto.Cipher +import javax.crypto.CipherInputStream +import javax.crypto.CipherOutputStream +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec object Encryption { + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val KEY_ALIAS = "io.rownd.android.keystore.v1" + private const val ENCRYPTION_ALGORITHM = KeyProperties.KEY_ALGORITHM_AES + private const val ENCRYPTION_BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM + private const val ENCRYPTION_PADDING = KeyProperties.ENCRYPTION_PADDING_NONE + private const val TRANSFORMATION_STRING = "$ENCRYPTION_ALGORITHM/$ENCRYPTION_BLOCK_MODE/$ENCRYPTION_PADDING" + private const val GCM_IV_LENGTH = 12 // GCM recommended IV size + private const val AES_KEY_SIZE = 256 + private val messageEncoder = Base64MessageEncoder() private val ls: LazySodiumAndroid = LazySodiumAndroid(SodiumAndroid(), messageEncoder) private val context: Context get() = Rownd.appHandleWrapper?.app?.get()?.applicationContext ?: throw EncryptionException("No context available. Did you call Rownd.configure()?") - private fun keyName(keyId: String?): String { - return "io.rownd.key.${keyId ?: "default"}" + private val keyStore: KeyStore by lazy { + KeyStore.getInstance(ANDROID_KEYSTORE).apply { + load(null) + } } - private fun getKeyFile(keyId: String): EncryptedFile { - val keyGenParameterSpec = MasterKeys.AES256_GCM_SPEC - val mainKeyAlias = MasterKeys.getOrCreate(keyGenParameterSpec) - return EncryptedFile.Builder( - File(context.filesDir, keyId), - context, - mainKeyAlias, - EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB - ).build() + private fun getOrCreateSecretKey(): SecretKey { + if (!keyStore.containsAlias(KEY_ALIAS)) { + val keyGenerator = KeyGenerator.getInstance(ENCRYPTION_ALGORITHM, ANDROID_KEYSTORE) + val spec = KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(ENCRYPTION_BLOCK_MODE) + .setEncryptionPaddings(ENCRYPTION_PADDING) + .setKeySize(AES_KEY_SIZE) + .build() + keyGenerator.init(spec) + return keyGenerator.generateKey() + } + return keyStore.getKey(KEY_ALIAS, null) as SecretKey + } + + private fun keyName(keyId: String?): String { + return "io.rownd.key.${keyId ?: "default"}" } fun doesKeyExist(keyId: String): Boolean { val keyFile = File(context.filesDir, keyName(keyId)) - return keyFile.exists() } fun storeKey(key: Key, keyId: String) { - val keyFile = getKeyFile(keyName(keyId)) - - keyFile.openFileOutput().apply { - write(key.asBytes) - flush() - close() + val file = File(context.filesDir, keyName(keyId)) + + val secretKey = getOrCreateSecretKey() + val cipher = Cipher.getInstance(TRANSFORMATION_STRING) + cipher.init(Cipher.ENCRYPT_MODE, secretKey) + + // The IV is written to the start of the file + val iv = cipher.iv + FileOutputStream(file).use { fileOut -> + fileOut.write(iv) + CipherOutputStream(fileOut, cipher).use { + it.write(key.asBytes) + } } } @@ -59,25 +91,34 @@ object Encryption { } fun loadKey(keyId: String): Key? { - val keyFile = getKeyFile(keyName(keyId)) + val file = File(context.filesDir, keyName(keyId)) + if (!file.exists()) { + return null + } try { - val inputStream = keyFile.openFileInput() - val byteArrayOutputStream = ByteArrayOutputStream() - - val buffer = ByteArray(1024) - var bytesRead: Int - while (inputStream.read(buffer).also { bytesRead = it } != -1) { - byteArrayOutputStream.write(buffer, 0, bytesRead) + FileInputStream(file).use { fileIn -> + // Read the IV from the start of the file + val iv = ByteArray(GCM_IV_LENGTH) + fileIn.read(iv) + + val secretKey = getOrCreateSecretKey() + val cipher = Cipher.getInstance(TRANSFORMATION_STRING) + val spec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) + + CipherInputStream(fileIn, cipher).use { cipherIn -> + val keyBytes = cipherIn.readBytes() + return Key.fromBytes(keyBytes) + } } - - val keyBytes = byteArrayOutputStream.toByteArray() - - return Key.fromBytes(keyBytes) } catch (error: IOException) { + Log.e("Rownd", "Failed to load encryption key (IO): ${error.message}", error) return null } catch (error: Exception) { - Log.e("Rownd", "Failed to load encryption key: ${error.message}") + Log.e("Rownd", "Failed to load encryption key: ${error.message}", error) + // It's possible the key is corrupt or something changed, delete it + file.delete() return null } } From 26f0c504a3439bbbb8bde9cd64a99f1f564e1bf1 Mon Sep 17 00:00:00 2001 From: Matt Hamann Date: Tue, 18 Nov 2025 12:35:40 -0500 Subject: [PATCH 2/8] chore: update to java 21 --- android/build.gradle | 4 ++-- app/build.gradle | 6 +++--- app_full/build.gradle.kts | 4 ++-- app_instant/build.gradle.kts | 4 ++-- base/build.gradle.kts | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/android/build.gradle b/android/build.gradle index 5387578..4006ee0 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -42,8 +42,8 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 } kotlinOptions { jvmTarget = '17' diff --git a/app/build.gradle b/app/build.gradle index 7ab2d27..d983a67 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -28,11 +28,11 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 } kotlinOptions { - jvmTarget = '17' + jvmTarget = '21' } buildFeatures { diff --git a/app_full/build.gradle.kts b/app_full/build.gradle.kts index 806e685..60ffae9 100644 --- a/app_full/build.gradle.kts +++ b/app_full/build.gradle.kts @@ -22,8 +22,8 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } kotlinOptions { jvmTarget = "17" diff --git a/app_instant/build.gradle.kts b/app_instant/build.gradle.kts index 97f09e3..9c1f46d 100644 --- a/app_instant/build.gradle.kts +++ b/app_instant/build.gradle.kts @@ -22,8 +22,8 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } kotlinOptions { jvmTarget = "17" diff --git a/base/build.gradle.kts b/base/build.gradle.kts index 49c94f3..aa7f1a6 100644 --- a/base/build.gradle.kts +++ b/base/build.gradle.kts @@ -36,8 +36,8 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } kotlinOptions { jvmTarget = "17" From 53808497dfefceb6f839e2c95bb6035f8da56442 Mon Sep 17 00:00:00 2001 From: Matt Hamann Date: Tue, 18 Nov 2025 12:41:20 -0500 Subject: [PATCH 3/8] BREAKING CHANGE: bump jvm target to 21 --- .github/workflows/pr-check.yaml | 4 ++-- android/build.gradle | 2 +- app_full/build.gradle.kts | 2 +- app_instant/build.gradle.kts | 2 +- base/build.gradle.kts | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr-check.yaml b/.github/workflows/pr-check.yaml index 0f1fa44..ca2a085 100644 --- a/.github/workflows/pr-check.yaml +++ b/.github/workflows/pr-check.yaml @@ -37,7 +37,7 @@ jobs: uses: actions/setup-java@v3 with: distribution: 'temurin' - java-version: '17' + java-version: '21' cache: 'gradle' - name: Build the app @@ -93,7 +93,7 @@ jobs: uses: actions/setup-java@v3 with: distribution: 'temurin' - java-version: '17' + java-version: '21' cache: 'gradle' # - name: Run detekt diff --git a/android/build.gradle b/android/build.gradle index 4006ee0..eff7825 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -46,7 +46,7 @@ android { targetCompatibility JavaVersion.VERSION_21 } kotlinOptions { - jvmTarget = '17' + jvmTarget = '21' } buildFeatures { diff --git a/app_full/build.gradle.kts b/app_full/build.gradle.kts index 60ffae9..3305785 100644 --- a/app_full/build.gradle.kts +++ b/app_full/build.gradle.kts @@ -26,7 +26,7 @@ android { targetCompatibility = JavaVersion.VERSION_21 } kotlinOptions { - jvmTarget = "17" + jvmTarget = "21" } buildFeatures { viewBinding = true diff --git a/app_instant/build.gradle.kts b/app_instant/build.gradle.kts index 9c1f46d..1f0483b 100644 --- a/app_instant/build.gradle.kts +++ b/app_instant/build.gradle.kts @@ -26,7 +26,7 @@ android { targetCompatibility = JavaVersion.VERSION_21 } kotlinOptions { - jvmTarget = "17" + jvmTarget = "21" } buildFeatures { compose = true diff --git a/base/build.gradle.kts b/base/build.gradle.kts index aa7f1a6..dd8aff0 100644 --- a/base/build.gradle.kts +++ b/base/build.gradle.kts @@ -40,7 +40,7 @@ android { targetCompatibility = JavaVersion.VERSION_21 } kotlinOptions { - jvmTarget = "17" + jvmTarget = "21" } dynamicFeatures += setOf( From 9a7420ac749da4c30202968841921e41936a9ded Mon Sep 17 00:00:00 2001 From: Matt Hamann Date: Tue, 18 Nov 2025 13:08:28 -0500 Subject: [PATCH 4/8] BREAKING CHANGE: remove unused client-side encryption --- android/build.gradle | 9 +- .../src/main/java/io/rownd/android/Rownd.kt | 4 - .../io/rownd/android/models/domain/User.kt | 28 +-- .../android/models/network/SignInLink.kt | 8 - .../io/rownd/android/models/network/User.kt | 29 +-- .../io/rownd/android/models/repos/UserRepo.kt | 40 ----- .../main/java/io/rownd/android/util/Base64.kt | 5 + .../java/io/rownd/android/util/Encryption.kt | 169 ------------------ app/build.gradle | 6 +- app_full/build.gradle.kts | 6 +- app_instant/build.gradle.kts | 6 +- base/build.gradle.kts | 6 +- 12 files changed, 22 insertions(+), 294 deletions(-) create mode 100644 android/src/main/java/io/rownd/android/util/Base64.kt delete mode 100644 android/src/main/java/io/rownd/android/util/Encryption.kt diff --git a/android/build.gradle b/android/build.gradle index eff7825..9ba6e9f 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -42,11 +42,11 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_21 - targetCompatibility JavaVersion.VERSION_21 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '21' + jvmTarget = '17' } buildFeatures { @@ -131,9 +131,6 @@ dependencies { implementation 'com.auth0.android:jwtdecode:2.0.2' implementation 'com.lyft.kronos:kronos-android:0.0.1-alpha11' - implementation "com.goterl:lazysodium-android:5.2.0@aar" - implementation "net.java.dev.jna:jna:5.18.1@aar" - // Passkeys & Google sign-in implementation "androidx.credentials:credentials:1.5.0" implementation "androidx.credentials:credentials-play-services-auth:1.5.0" diff --git a/android/src/main/java/io/rownd/android/Rownd.kt b/android/src/main/java/io/rownd/android/Rownd.kt index bc330e9..b8b381a 100644 --- a/android/src/main/java/io/rownd/android/Rownd.kt +++ b/android/src/main/java/io/rownd/android/Rownd.kt @@ -370,10 +370,6 @@ class RowndClient( } } - fun isEncryptionPossible(): Boolean { - return userRepo.isEncryptionPossible() - } - // Internal stuff internal fun displayHub( targetPage: HubPageSelector, diff --git a/android/src/main/java/io/rownd/android/models/domain/User.kt b/android/src/main/java/io/rownd/android/models/domain/User.kt index 20f083d..a21a542 100644 --- a/android/src/main/java/io/rownd/android/models/domain/User.kt +++ b/android/src/main/java/io/rownd/android/models/domain/User.kt @@ -1,11 +1,9 @@ package io.rownd.android.models.domain -import android.util.Log import io.rownd.android.models.repos.StateRepo import io.rownd.android.models.repos.UserRepo import io.rownd.android.util.AnyValueSerializer import io.rownd.android.util.AuthLevel -import io.rownd.android.util.Encryption import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import io.rownd.android.models.network.User as NetworkUser @@ -21,34 +19,10 @@ data class User( ) { fun asNetworkModel(stateRepo: StateRepo, userRepo: UserRepo): NetworkUser { return NetworkUser( - data = dataAsEncrypted(stateRepo, userRepo), + data = data, redacted = redacted, state = state, authLevel = authLevel, ) } - - internal fun dataAsEncrypted(stateRepo: StateRepo, userRepo: UserRepo): Map { - val encKeyId = userRepo.ensureEncryptionKey(this) ?: return data - - val data = data.toMutableMap() - - // Encrypt user fields - for (entry in data.entries) { - val (key, _) = entry - if (stateRepo.state.value.appConfig.schema[key]?.encryption?.state == AppSchemaEncryptionState.Enabled && entry.value is String) { - val value = entry.value as String - try { - val encrypted: String = Encryption.encrypt(value, encKeyId) - data[key] = encrypted - } catch (error: Exception) { - Log.d( - "RowndUserNetwork", - "Failed to encrypt user data value. Error: ${error.message}" - ) - } - } - } - return data - } } \ No newline at end of file diff --git a/android/src/main/java/io/rownd/android/models/network/SignInLink.kt b/android/src/main/java/io/rownd/android/models/network/SignInLink.kt index e0a6c8e..cb6c30b 100644 --- a/android/src/main/java/io/rownd/android/models/network/SignInLink.kt +++ b/android/src/main/java/io/rownd/android/models/network/SignInLink.kt @@ -21,7 +21,6 @@ import io.rownd.android.models.domain.AuthState import io.rownd.android.models.repos.StateAction import io.rownd.android.models.repos.UserRepo import io.rownd.android.util.AuthenticatedApiClient -import io.rownd.android.util.Encryption import io.rownd.android.util.KtorApiClient import io.rownd.android.util.RowndContext import io.rownd.android.util.RowndEvent @@ -79,10 +78,8 @@ class SignInLinkApi @Inject constructor() { internal suspend fun signInWithLink(url: String) { var signInUrl = url val urlObj = url.toUri() - var encKey: String? = null if (urlObj.fragment != null) { - encKey = urlObj.fragment signInUrl = signInUrl.replace("#${urlObj.fragment}", "") } @@ -93,11 +90,6 @@ class SignInLinkApi @Inject constructor() { try { val authBody = authenticateWithSignInLink(signInUrl) - if (encKey != null) { - Encryption.deleteKey(authBody.appUserId) - Encryption.storeKey(encKey, authBody.appUserId) - } - Rownd.store.dispatch( StateAction.SetAuth( AuthState( diff --git a/android/src/main/java/io/rownd/android/models/network/User.kt b/android/src/main/java/io/rownd/android/models/network/User.kt index 3b90b47..a951ed9 100644 --- a/android/src/main/java/io/rownd/android/models/network/User.kt +++ b/android/src/main/java/io/rownd/android/models/network/User.kt @@ -1,12 +1,9 @@ package io.rownd.android.models.network -import android.util.Log -import io.rownd.android.models.domain.AppSchemaEncryptionState import io.rownd.android.models.repos.StateRepo import io.rownd.android.models.repos.UserRepo import io.rownd.android.util.AnyValueSerializer import io.rownd.android.util.AuthLevel -import io.rownd.android.util.Encryption import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import io.rownd.android.models.domain.User as DomainUser @@ -21,34 +18,10 @@ data class User( ) { fun asDomainModel(stateRepo: StateRepo, userRepo: UserRepo): DomainUser { return DomainUser( - data = dataAsDecrypted(stateRepo, userRepo), + data = data, redacted = redacted.toMutableList(), state = state, authLevel = authLevel ) } - - internal fun dataAsDecrypted(stateRepo: StateRepo, userRepo: UserRepo): Map { - val encKeyId = userRepo.ensureEncryptionKey(DomainUser(data = data)) ?: return data - - val data = data.toMutableMap() - - // Decrypt user fields - for (entry in data.entries) { - val (key, _) = entry - if (stateRepo.state.value.appConfig.schema[key]?.encryption?.state == AppSchemaEncryptionState.Enabled && entry.value is String) { - val value = entry.value as String - try { - val decrypted: String = Encryption.decrypt(value, encKeyId) - data[key] = decrypted - } catch (error: Exception) { - Log.d( - "RowndUserNetwork", - "Failed to decrypt user data value. Error: ${error.message} ${error.stackTraceToString()}" - ) - } - } - } - return data - } } diff --git a/android/src/main/java/io/rownd/android/models/repos/UserRepo.kt b/android/src/main/java/io/rownd/android/models/repos/UserRepo.kt index 4eb561a..93f9e7d 100644 --- a/android/src/main/java/io/rownd/android/models/repos/UserRepo.kt +++ b/android/src/main/java/io/rownd/android/models/repos/UserRepo.kt @@ -9,8 +9,6 @@ import io.ktor.client.request.setBody import io.ktor.http.HttpStatusCode import io.rownd.android.models.domain.User import io.rownd.android.util.AuthenticatedApiClient -import io.rownd.android.util.Encryption -import io.rownd.android.util.EncryptionException import io.rownd.android.util.RowndContext import io.rownd.android.util.RowndException import kotlinx.coroutines.CoroutineScope @@ -19,7 +17,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import javax.inject.Inject import javax.inject.Singleton -import kotlin.collections.set import io.rownd.android.models.network.User as NetworkUser @Singleton @@ -122,41 +119,4 @@ class UserRepo @Inject constructor() { return saveUserAsync(updatedUser) } - fun getKeyId(user: User): String { - return get("user_id") - ?: throw EncryptionException("An encryption key was requested, but the user has not been loaded yet. Are you signed in?") - } - - internal fun ensureEncryptionKey(user: User): String? { - try { - val keyId = getKeyId(user) - - var key = Encryption.loadKey(keyId) - - if (key == null) { - key = Encryption.generateKey() - Encryption.storeKey(key, keyId) - return keyId - } - - return keyId - } catch (error: Exception) { - Log.e( - "RowndUser", - "Failed to ensure that an encryption key exists: ${error.message}" - ) - return null - } - } - - fun isEncryptionPossible(): Boolean { - try { - val key = Encryption.loadKey(getKeyId(stateRepo.state.value.user)) ?: return false - - return true - } catch (error: Exception) { - return false - } - } - } \ No newline at end of file diff --git a/android/src/main/java/io/rownd/android/util/Base64.kt b/android/src/main/java/io/rownd/android/util/Base64.kt new file mode 100644 index 0000000..2f03687 --- /dev/null +++ b/android/src/main/java/io/rownd/android/util/Base64.kt @@ -0,0 +1,5 @@ +package io.rownd.android.util + +import java.util.Base64 + +fun ByteArray.toBase64(): String = String(Base64.getEncoder().encode(this)) \ No newline at end of file diff --git a/android/src/main/java/io/rownd/android/util/Encryption.kt b/android/src/main/java/io/rownd/android/util/Encryption.kt deleted file mode 100644 index 0f6a565..0000000 --- a/android/src/main/java/io/rownd/android/util/Encryption.kt +++ /dev/null @@ -1,169 +0,0 @@ -package io.rownd.android.util - -import android.content.Context -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties -import android.util.Log -import com.goterl.lazysodium.LazySodiumAndroid -import com.goterl.lazysodium.SodiumAndroid -import com.goterl.lazysodium.exceptions.SodiumException -import com.goterl.lazysodium.interfaces.SecretBox -import com.goterl.lazysodium.utils.Base64MessageEncoder -import com.goterl.lazysodium.utils.Key -import io.rownd.android.Rownd -import java.io.* -import java.security.KeyStore -import java.util.* -import javax.crypto.Cipher -import javax.crypto.CipherInputStream -import javax.crypto.CipherOutputStream -import javax.crypto.KeyGenerator -import javax.crypto.SecretKey -import javax.crypto.spec.GCMParameterSpec - - -object Encryption { - private const val ANDROID_KEYSTORE = "AndroidKeyStore" - private const val KEY_ALIAS = "io.rownd.android.keystore.v1" - private const val ENCRYPTION_ALGORITHM = KeyProperties.KEY_ALGORITHM_AES - private const val ENCRYPTION_BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM - private const val ENCRYPTION_PADDING = KeyProperties.ENCRYPTION_PADDING_NONE - private const val TRANSFORMATION_STRING = "$ENCRYPTION_ALGORITHM/$ENCRYPTION_BLOCK_MODE/$ENCRYPTION_PADDING" - private const val GCM_IV_LENGTH = 12 // GCM recommended IV size - private const val AES_KEY_SIZE = 256 - - private val messageEncoder = Base64MessageEncoder() - private val ls: LazySodiumAndroid = LazySodiumAndroid(SodiumAndroid(), messageEncoder) - private val context: Context - get() = Rownd.appHandleWrapper?.app?.get()?.applicationContext ?: throw EncryptionException("No context available. Did you call Rownd.configure()?") - - private val keyStore: KeyStore by lazy { - KeyStore.getInstance(ANDROID_KEYSTORE).apply { - load(null) - } - } - - private fun getOrCreateSecretKey(): SecretKey { - if (!keyStore.containsAlias(KEY_ALIAS)) { - val keyGenerator = KeyGenerator.getInstance(ENCRYPTION_ALGORITHM, ANDROID_KEYSTORE) - val spec = KeyGenParameterSpec.Builder( - KEY_ALIAS, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT - ) - .setBlockModes(ENCRYPTION_BLOCK_MODE) - .setEncryptionPaddings(ENCRYPTION_PADDING) - .setKeySize(AES_KEY_SIZE) - .build() - keyGenerator.init(spec) - return keyGenerator.generateKey() - } - return keyStore.getKey(KEY_ALIAS, null) as SecretKey - } - - private fun keyName(keyId: String?): String { - return "io.rownd.key.${keyId ?: "default"}" - } - - fun doesKeyExist(keyId: String): Boolean { - val keyFile = File(context.filesDir, keyName(keyId)) - return keyFile.exists() - } - - fun storeKey(key: Key, keyId: String) { - val file = File(context.filesDir, keyName(keyId)) - - val secretKey = getOrCreateSecretKey() - val cipher = Cipher.getInstance(TRANSFORMATION_STRING) - cipher.init(Cipher.ENCRYPT_MODE, secretKey) - - // The IV is written to the start of the file - val iv = cipher.iv - FileOutputStream(file).use { fileOut -> - fileOut.write(iv) - CipherOutputStream(fileOut, cipher).use { - it.write(key.asBytes) - } - } - } - - fun storeKey(key: String, keyId: String) { - storeKey(Key.fromBase64String(key), keyId) - } - - fun loadKey(keyId: String): Key? { - val file = File(context.filesDir, keyName(keyId)) - if (!file.exists()) { - return null - } - - try { - FileInputStream(file).use { fileIn -> - // Read the IV from the start of the file - val iv = ByteArray(GCM_IV_LENGTH) - fileIn.read(iv) - - val secretKey = getOrCreateSecretKey() - val cipher = Cipher.getInstance(TRANSFORMATION_STRING) - val spec = GCMParameterSpec(128, iv) - cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) - - CipherInputStream(fileIn, cipher).use { cipherIn -> - val keyBytes = cipherIn.readBytes() - return Key.fromBytes(keyBytes) - } - } - } catch (error: IOException) { - Log.e("Rownd", "Failed to load encryption key (IO): ${error.message}", error) - return null - } catch (error: Exception) { - Log.e("Rownd", "Failed to load encryption key: ${error.message}", error) - // It's possible the key is corrupt or something changed, delete it - file.delete() - return null - } - } - - fun deleteKey(keyId: String) { - val keyFile = File(context.filesDir, keyName(keyId)) - keyFile.delete() - } - - fun generateKey(): Key { - return ls.cryptoSecretBoxKeygen() - } - - @Throws(SodiumException::class) - fun encrypt(plaintext: String, withKey: Key): String { - val nonce = ls.randomBytesBuf(SecretBox.NONCEBYTES) - val ciphertext = ls.cryptoSecretBoxEasy(plaintext, nonce, withKey) - - return messageEncoder.encode(nonce + messageEncoder.decode(ciphertext)) - } - - @Throws(SodiumException::class) - fun encrypt(plaintext: String, keyId: String): String { - val key = loadKey(keyId) ?: throw EncryptionException("The requested key '$keyId' could not be found") - return encrypt(plaintext, key) - } - - @Throws(SodiumException::class) - fun decrypt(ciphertext: String, withKey: Key): String { - val noncedCipherByteArray = messageEncoder.decode(ciphertext) - val nonce = noncedCipherByteArray.copyOfRange(0, SecretBox.NONCEBYTES) - val cipherTextBytes = noncedCipherByteArray.copyOfRange(SecretBox.NONCEBYTES, noncedCipherByteArray.size) - return ls.cryptoSecretBoxOpenEasy(messageEncoder.encode(cipherTextBytes), nonce, withKey) - } - - @Throws(SodiumException::class) - fun decrypt(ciphertext: String, keyId: String): String { - val key = loadKey(keyId) ?: throw EncryptionException("The requested key '$keyId' could not be found") - return decrypt(ciphertext, key) - } -} - -fun ByteArray.toBase64(): String = String(Base64.getEncoder().encode(this)) - -val Key.asBase64String: String - get() = this.asBytes.toBase64() - -class EncryptionException(message: String) : Exception(message) \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index d983a67..7ab2d27 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -28,11 +28,11 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_21 - targetCompatibility JavaVersion.VERSION_21 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '21' + jvmTarget = '17' } buildFeatures { diff --git a/app_full/build.gradle.kts b/app_full/build.gradle.kts index 3305785..806e685 100644 --- a/app_full/build.gradle.kts +++ b/app_full/build.gradle.kts @@ -22,11 +22,11 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = "21" + jvmTarget = "17" } buildFeatures { viewBinding = true diff --git a/app_instant/build.gradle.kts b/app_instant/build.gradle.kts index 1f0483b..97f09e3 100644 --- a/app_instant/build.gradle.kts +++ b/app_instant/build.gradle.kts @@ -22,11 +22,11 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = "21" + jvmTarget = "17" } buildFeatures { compose = true diff --git a/base/build.gradle.kts b/base/build.gradle.kts index dd8aff0..49c94f3 100644 --- a/base/build.gradle.kts +++ b/base/build.gradle.kts @@ -36,11 +36,11 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = "21" + jvmTarget = "17" } dynamicFeatures += setOf( From 63f4c343b755cc215e303648e2fc4cf2e5427f45 Mon Sep 17 00:00:00 2001 From: Matt Hamann Date: Tue, 18 Nov 2025 13:38:38 -0500 Subject: [PATCH 5/8] chore: update connected check --- .github/workflows/pr-check.yaml | 2 +- .idea/misc.xml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-check.yaml b/.github/workflows/pr-check.yaml index ca2a085..fe976de 100644 --- a/.github/workflows/pr-check.yaml +++ b/.github/workflows/pr-check.yaml @@ -109,7 +109,7 @@ jobs: target: ${{ matrix.target }} arch: ${{ matrix.api-level > 27 && 'x86_64' || 'x86' }} profile: pixel_3a - script: ./gradlew :android:connectedDebugAndroidTest --stacktrace + script: ./gradlew connectedCheck - name: Upload Reports uses: actions/upload-artifact@v4 diff --git a/.idea/misc.xml b/.idea/misc.xml index d514100..0aecc4c 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -19,6 +19,7 @@ + From 0b18d252f839263e22b2830d3905f926352b4f93 Mon Sep 17 00:00:00 2001 From: Matt Hamann Date: Tue, 18 Nov 2025 13:48:44 -0500 Subject: [PATCH 6/8] chore: remove encryption test --- .../android/EncryptionInstrumentedTest.kt | 113 ------------------ 1 file changed, 113 deletions(-) delete mode 100644 android/src/androidTest/java/io/rownd/android/EncryptionInstrumentedTest.kt diff --git a/android/src/androidTest/java/io/rownd/android/EncryptionInstrumentedTest.kt b/android/src/androidTest/java/io/rownd/android/EncryptionInstrumentedTest.kt deleted file mode 100644 index bbd46ce..0000000 --- a/android/src/androidTest/java/io/rownd/android/EncryptionInstrumentedTest.kt +++ /dev/null @@ -1,113 +0,0 @@ -package io.rownd.android - -import android.app.Application -import android.app.Instrumentation -import android.content.Context -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.goterl.lazysodium.utils.Key -import io.rownd.android.util.Encryption -import io.rownd.android.util.asBase64String -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import java.util.UUID - -class RowndTest : Application() - -@RunWith(AndroidJUnit4::class) -class EncryptionInstrumentedTest { - - private val KEY_ID = "test-key" - - lateinit var instrumentationContext: Context - - @Before - fun setup() { - val instrumentation = InstrumentationRegistry.getInstrumentation() - instrumentationContext = instrumentation.context - - val app = Instrumentation.newApplication(Application::class.java, instrumentationContext) - Rownd.config.stateFileName = "test_datastore_${UUID.randomUUID()}.json" - Rownd.configure(app, "") - } - - @After - fun teardown() { - // Clean up old keys - Encryption.deleteKey(KEY_ID) - } - - @Test - fun key_generate() { - val key = Encryption.generateKey() - println(key.asBase64String) - assertNotNull(key) - } - - @Test - fun encrypt_data() { - val key = Key.fromBase64String("4f4a6IInDuSga0wyQQQpMSrDHIZ/ryoc9w6s5xVF/VQ=") - - val plainText = "This super secret string will never be known." - val cipherText = Encryption.encrypt(plainText, key) - - println(cipherText) - - assertNotNull(cipherText) - } - - @Test - fun decrypt_data() { - val key = Key.fromBase64String("4f4a6IInDuSga0wyQQQpMSrDHIZ/ryoc9w6s5xVF/VQ=") - val expectedPlainText = "This super secret string will never be known." - val cipherText = "Di0IyYbC141WIPKzFnlsQc0BIi1AWKSpLf6Th9TcDDJiidPfkVazXtFibnsqJyKFaQf7SaF68yihnqJXidodfKqKzjM2MnbHbh+O8wpxFO3gO6OhVg==" - - val computedPlainText = Encryption.decrypt(cipherText, key) - - assertEquals(computedPlainText, expectedPlainText) - } - - @Test - fun encrypt_then_decrypt() { - val key = Encryption.generateKey() - - val plainText = "This super secret string will never be known." - val cipherText = Encryption.encrypt(plainText, key) - - println(cipherText) - - val computedPlainText = Encryption.decrypt(cipherText, key) - - assertEquals(computedPlainText, plainText) - } - - @Test - fun store_encryption_key() { - val key = Encryption.generateKey() - Encryption.storeKey(key, KEY_ID) - - assert(true) // key stored successfully - } - - @Test - fun load_encryption_key() { - val key = Encryption.generateKey() - Encryption.storeKey(key, KEY_ID) - - val storedKey = Encryption.loadKey(KEY_ID) - - assertEquals(key.asBase64String, storedKey?.asBase64String) - } - - @Test - fun load_nonexistant_key() { - val storedKey = Encryption.loadKey(KEY_ID) - - assertNull(storedKey) - } -} \ No newline at end of file From 4cf896607a012e05b405aa063343d3e075543e88 Mon Sep 17 00:00:00 2001 From: Matt Hamann Date: Tue, 18 Nov 2025 14:12:13 -0500 Subject: [PATCH 7/8] chore: ensure tink is installed for jwt tests --- android/build.gradle | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/android/build.gradle b/android/build.gradle index 9ba6e9f..a09fec4 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -142,10 +142,11 @@ dependencies { kapt "com.google.dagger:dagger-compiler:$dagger_version" // For instrumentation tests - androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1' + androidTestImplementation 'androidx.test.ext:junit-ktx:1.3.0' testImplementation("io.ktor:ktor-client-mock:$ktor_version") androidTestImplementation("io.ktor:ktor-client-mock:$ktor_version") - androidTestImplementation 'com.nimbusds:nimbus-jose-jwt:7.8.1' + androidTestImplementation 'com.nimbusds:nimbus-jose-jwt:10.6' + androidTestImplementation 'com.google.crypto.tink:tink-android:1.19.0' androidTestImplementation "com.google.dagger:dagger:$dagger_version" kaptAndroidTest "com.google.dagger:dagger-compiler:$dagger_version" From f4fbd8757531d5a96384645778a891f83a7118f3 Mon Sep 17 00:00:00 2001 From: Matt Hamann Date: Tue, 18 Nov 2025 14:35:34 -0500 Subject: [PATCH 8/8] chore: test fixups --- .../io/rownd/rowndtestsandbox/ExampleInstrumentedTest.kt | 8 +++----- .../app_instant/ExampleInstrumentedTest.kt | 8 +++----- .../app_instant/ExampleInstrumentedTest.kt | 8 +++----- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/app/src/androidTest/java/io/rownd/rowndtestsandbox/ExampleInstrumentedTest.kt b/app/src/androidTest/java/io/rownd/rowndtestsandbox/ExampleInstrumentedTest.kt index 5ed3c94..4312132 100644 --- a/app/src/androidTest/java/io/rownd/rowndtestsandbox/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/io/rownd/rowndtestsandbox/ExampleInstrumentedTest.kt @@ -1,13 +1,11 @@ package io.rownd.rowndtestsandbox -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 - +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* - /** * Instrumented test, which will execute on an Android device. * @@ -19,6 +17,6 @@ class ExampleInstrumentedTest { fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("io.rownd.rowndtestsandbox", appContext.packageName) + assertEquals("io.rownd.rowndtestsandbox.app", appContext.packageName) } } \ No newline at end of file diff --git a/app_full/src/androidTest/java/io/rownd/rowndtestsandbox/app_instant/ExampleInstrumentedTest.kt b/app_full/src/androidTest/java/io/rownd/rowndtestsandbox/app_instant/ExampleInstrumentedTest.kt index 08d14d3..d31ec50 100644 --- a/app_full/src/androidTest/java/io/rownd/rowndtestsandbox/app_instant/ExampleInstrumentedTest.kt +++ b/app_full/src/androidTest/java/io/rownd/rowndtestsandbox/app_instant/ExampleInstrumentedTest.kt @@ -1,13 +1,11 @@ package io.rownd.rowndtestsandbox.app_instant -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 - +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* - /** * Instrumented test, which will execute on an Android device. * @@ -19,6 +17,6 @@ class ExampleInstrumentedTest { fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("io.rownd.rowndtestsandbox.app_full", appContext.packageName) + assertEquals("io.rownd.rowndtestsandbox", appContext.packageName) } } \ No newline at end of file diff --git a/app_instant/src/androidTest/java/io/rownd/rowndtestsandbox/app_instant/ExampleInstrumentedTest.kt b/app_instant/src/androidTest/java/io/rownd/rowndtestsandbox/app_instant/ExampleInstrumentedTest.kt index ef246d3..d31ec50 100644 --- a/app_instant/src/androidTest/java/io/rownd/rowndtestsandbox/app_instant/ExampleInstrumentedTest.kt +++ b/app_instant/src/androidTest/java/io/rownd/rowndtestsandbox/app_instant/ExampleInstrumentedTest.kt @@ -1,13 +1,11 @@ package io.rownd.rowndtestsandbox.app_instant -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 - +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* - /** * Instrumented test, which will execute on an Android device. * @@ -19,6 +17,6 @@ class ExampleInstrumentedTest { fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("io.rownd.rowndtestsandbox.app_instant", appContext.packageName) + assertEquals("io.rownd.rowndtestsandbox", appContext.packageName) } } \ No newline at end of file