diff --git a/.github/workflows/pr-check.yaml b/.github/workflows/pr-check.yaml index 0f1fa44..fe976de 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 @@ -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 @@ + diff --git a/android/build.gradle b/android/build.gradle index 7ef58be..a09fec4 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,25 +131,22 @@ 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" - // 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" 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" 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 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 3400a82..0000000 --- a/android/src/main/java/io/rownd/android/util/Encryption.kt +++ /dev/null @@ -1,128 +0,0 @@ -package io.rownd.android.util - -import android.content.Context -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 -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.util.* - - -object Encryption { - 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 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() - } - - 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() - } - } - - fun storeKey(key: String, keyId: String) { - storeKey(Key.fromBase64String(key), keyId) - } - - fun loadKey(keyId: String): Key? { - val keyFile = getKeyFile(keyName(keyId)) - - 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) - } - - val keyBytes = byteArrayOutputStream.toByteArray() - - return Key.fromBytes(keyBytes) - } catch (error: IOException) { - return null - } catch (error: Exception) { - Log.e("Rownd", "Failed to load encryption key: ${error.message}") - 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/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