diff --git a/CHANGELOG.md b/CHANGELOG.md index 74ef8c00d2..fdd46fc2cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### Enhancements * [Sync] Added option to use managed WebSockets via OkHttp instead of Realm's built-in WebSocket client for Sync traffic (Only Android and JVM targets for now). Managed WebSockets offer improved support for proxies and firewalls that require authentication. This feature is currently opt-in and can be enabled by using `AppConfiguration.usePlatformNetworking()`. Managed WebSockets will become the default in a future version. (PR [#1528](https://github.com/realm/realm-kotlin/pull/1528)). * `AutoClientResetFailed` exception now reports as the throwable cause any user exceptions that might occur during a client reset. (Issue [#1580](https://github.com/realm/realm-kotlin/issues/1580)) +* Added an experimental configuration API which will allow to pass the encryption key using a callback https://github.com/realm/realm-kotlin/pull/1636. ### Fixed * Cache notification callback JNI references at startup to ensure that symbols can be resolved in core callbacks. (Issue [#1577](https://github.com/realm/realm-kotlin/issues/1577)) diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index bcb85872dd..6b7b413b22 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -62,7 +62,7 @@ val HOST_OS: OperatingSystem = findHostOs() object Realm { val ciBuild = (System.getenv("JENKINS_HOME") != null || System.getenv("CI") != null) - const val version = "1.14.0-SNAPSHOT" + const val version = "1.14.0-ENCRYPTION-POC-SNAPSHOT" const val group = "io.realm.kotlin" const val projectUrl = "https://realm.io" const val pluginPortalId = "io.realm.kotlin" diff --git a/packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt b/packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt index 6a846f05f0..51358903d1 100644 --- a/packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt +++ b/packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt @@ -192,6 +192,7 @@ expect object RealmInterop { fun realm_config_set_schema(config: RealmConfigurationPointer, schema: RealmSchemaPointer) fun realm_config_set_max_number_of_active_versions(config: RealmConfigurationPointer, maxNumberOfVersions: Long) fun realm_config_set_encryption_key(config: RealmConfigurationPointer, encryptionKey: ByteArray) + fun realm_config_set_encryption_key_from_pointer(config: RealmConfigurationPointer, aesEncryptionKeyAddress: Long) fun realm_config_get_encryption_key(config: RealmConfigurationPointer): ByteArray? fun realm_config_set_should_compact_on_launch_function(config: RealmConfigurationPointer, callback: CompactOnLaunchCallback) fun realm_config_set_migration_function(config: RealmConfigurationPointer, callback: MigrationCallback) diff --git a/packages/cinterop/src/jvm/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt b/packages/cinterop/src/jvm/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt index b79c41f4f4..8e922b7ec0 100644 --- a/packages/cinterop/src/jvm/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt +++ b/packages/cinterop/src/jvm/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt @@ -184,6 +184,10 @@ actual object RealmInterop { realmc.realm_config_set_encryption_key(config.cptr(), encryptionKey, encryptionKey.size.toLong()) } + actual fun realm_config_set_encryption_key_from_pointer(config: RealmConfigurationPointer, aesEncryptionKeyAddress: Long) { + realmc.realm_config_set_encryption_key_from_pointer(config.cptr(), aesEncryptionKeyAddress) + } + actual fun realm_config_get_encryption_key(config: RealmConfigurationPointer): ByteArray? { val key = ByteArray(ENCRYPTION_KEY_LENGTH) val keyLength: Long = realmc.realm_config_get_encryption_key(config.cptr(), key) diff --git a/packages/cinterop/src/nativeDarwin/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt b/packages/cinterop/src/nativeDarwin/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt index 3b0faf5100..5e7befd34d 100644 --- a/packages/cinterop/src/nativeDarwin/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt +++ b/packages/cinterop/src/nativeDarwin/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt @@ -54,6 +54,7 @@ import kotlinx.cinterop.CPointerVar import kotlinx.cinterop.CPointerVarOf import kotlinx.cinterop.CValue import kotlinx.cinterop.CVariable +import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.LongVar import kotlinx.cinterop.MemScope import kotlinx.cinterop.StableRef @@ -77,6 +78,7 @@ import kotlinx.cinterop.readValue import kotlinx.cinterop.refTo import kotlinx.cinterop.set import kotlinx.cinterop.staticCFunction +import kotlinx.cinterop.toCPointer import kotlinx.cinterop.toCStringArray import kotlinx.cinterop.toCValues import kotlinx.cinterop.toKString @@ -419,6 +421,16 @@ actual object RealmInterop { } } + @OptIn(ExperimentalForeignApi::class) + actual fun realm_config_set_encryption_key_from_pointer(config: RealmConfigurationPointer, aesEncryptionKeyAddress: Long) { + memScoped { // Ensure memory cleanup + val ptr = aesEncryptionKeyAddress.toCPointer>() + val encryptionKey = ByteArray(64) + memcpy(encryptionKey.refTo(0), ptr, 64u) + realm_config_set_encryption_key(config, encryptionKey) + } + } + actual fun realm_config_get_encryption_key(config: RealmConfigurationPointer): ByteArray? { memScoped { val encryptionKey = ByteArray(ENCRYPTION_KEY_LENGTH) diff --git a/packages/external/core b/packages/external/core index 71f94d75e2..f1559304c5 160000 --- a/packages/external/core +++ b/packages/external/core @@ -1 +1 @@ -Subproject commit 71f94d75e25bfc8913fcd93ae8de550b57577a4a +Subproject commit f1559304c52815f7adaa70b4a31bf862acf03061 diff --git a/packages/jni-swig-stub/src/main/jni/realm_api_helpers.cpp b/packages/jni-swig-stub/src/main/jni/realm_api_helpers.cpp index 54522828ba..323421596c 100644 --- a/packages/jni-swig-stub/src/main/jni/realm_api_helpers.cpp +++ b/packages/jni-swig-stub/src/main/jni/realm_api_helpers.cpp @@ -937,6 +937,12 @@ void realm_sync_websocket_closed(int64_t observer_ptr, bool was_clean, int error realm_sync_socket_websocket_closed(reinterpret_cast(observer_ptr), was_clean, static_cast(error_code), reason); } +void realm_config_set_encryption_key_from_pointer(realm_config_t* config, int64_t aesKeyAddress) { + uint8_t key_array[64]; + std::memcpy(key_array, reinterpret_cast(aesKeyAddress), 64); + realm_config_set_encryption_key(config, key_array, 64); +} + realm_sync_socket_t* realm_sync_websocket_new(int64_t sync_client_config_ptr, jobject websocket_transport) { auto jenv = get_env(false); // Always called from JVM realm_sync_socket_t* socket_provider = realm_sync_socket_new(jenv->NewGlobalRef(websocket_transport), /*userdata*/ diff --git a/packages/jni-swig-stub/src/main/jni/realm_api_helpers.h b/packages/jni-swig-stub/src/main/jni/realm_api_helpers.h index 84caf586dc..16baf7aa5e 100644 --- a/packages/jni-swig-stub/src/main/jni/realm_api_helpers.h +++ b/packages/jni-swig-stub/src/main/jni/realm_api_helpers.h @@ -161,4 +161,5 @@ bool realm_sync_websocket_message(int64_t observer_ptr, jbyteArray data, size_t void realm_sync_websocket_closed(int64_t observer_ptr, bool was_clean, int error_code, const char* reason); +void realm_config_set_encryption_key_from_pointer(realm_config_t* config, int64_t aesKeyAddress); #endif //TEST_REALM_API_HELPERS_H diff --git a/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/Configuration.kt b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/Configuration.kt index daf8e6a42f..59fd093752 100644 --- a/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/Configuration.kt +++ b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/Configuration.kt @@ -17,6 +17,7 @@ package io.realm.kotlin import io.realm.kotlin.Configuration.SharedBuilder +import io.realm.kotlin.annotations.ExperimentalEncryptionCallbackApi import io.realm.kotlin.internal.MISSING_PLUGIN_MESSAGE import io.realm.kotlin.internal.REALM_FILE_EXTENSION import io.realm.kotlin.internal.platform.PATH_SEPARATOR @@ -106,6 +107,24 @@ public data class InitialRealmFileConfiguration( val checksum: String? ) +@ExperimentalEncryptionCallbackApi +public interface EncryptionKeyCallback { + /** + * Provides the native memory address of the 64 byte array containing the key used to encrypt and decrypt the Realm file. + * This can be called multiple times internally, so the key needs to be the same between calls. + * + * Note: The Realm SDK is not responsible of checking that the pointer is a valid 64 byte array, providing an invalid address will probably + * causes a segmentation fault and will crash the app. + */ + public fun keyPointer(): Long + + /** + * This callback will be invoked by Realm after it's open. This hint to the user that the key provided in [keyPointer] can now be released. + * This will be called once the Realm is open and it's safe to dispose of the encryption key. + */ + public fun releaseKey() +} + /** * Base configuration options shared between all realm configuration types. */ @@ -153,6 +172,14 @@ public interface Configuration { */ public val encryptionKey: ByteArray? + /** + * Native memory address of the 64 byte array containing the key used to encrypt and decrypt the Realm file. + * + * @return null on unencrypted Realms. + */ + @OptIn(ExperimentalEncryptionCallbackApi::class) + public val encryptionKeyAsCallback: EncryptionKeyCallback? + /** * Callback that determines if the realm file should be compacted as part of opening it. * @@ -234,6 +261,8 @@ public interface Configuration { protected var writeDispatcher: CoroutineDispatcher? = null protected var schemaVersion: Long = 0 protected var encryptionKey: ByteArray? = null + @OptIn(ExperimentalEncryptionCallbackApi::class) + protected var encryptionKeyAsCallback: EncryptionKeyCallback? = null protected var compactOnLaunchCallback: CompactOnLaunchCallback? = null protected var initialDataCallback: InitialDataCallback? = null protected var inMemory: Boolean = false @@ -354,6 +383,51 @@ public interface Configuration { public fun encryptionKey(encryptionKey: ByteArray): S = apply { this.encryptionKey = validateEncryptionKey(encryptionKey) } as S + /** + * Similar to [encryptionKey] but instead this will read the encryption key from native memory. + * This can enhance the security of the app, since it reduces the window where the key is available in clear + * in memory (avoid memory dump attack). Once the Realm is open, one can zero-out the memory region holding the key + * as it will be already passed to the C++ storage engine. + * + * There's also extra protection for JVM Windows target, where the underlying storage engine uses the Windows Kernel + * to encrypt/decrypt the Realm's encryption key before each usage. + * + * Note: The RealmConfiguration doesn't take ownership of this native memory, the caller is responsible of disposing it + * appropriately after the Realm is open using the [EncryptionKeyCallback.releaseKey]. + * + * @param encryptionKeyAsCallback Callback providing address/pointer to a 64-byte array containing the AES encryption key. + * This array should be in native memory to avoid copying the key into garbage collected heap memory (for JVM targets). + * + * One way to create such an array in JVM is to use JNI or use `sun.misc.Unsafe` as follow: + * + *``` + * import sun.misc.Unsafe + * + * val field = Unsafe::class.java.getDeclaredField("theUnsafe") + * field.isAccessible = true + * val unsafe: Unsafe = field.get(null) as Unsafe + * + * val key = Random.nextBytes(64) // Replace with your actual AES key + * val keyPointer: Long = unsafe.allocateMemory(key.size.toLong()) + * for (i in key.indices) { // Write the key bytes to native memory + * unsafe.putByte(keyPointer + i, key[i]) + * } + * + * val encryptedConf = RealmConfiguration + * .Builder(schema = setOf(Sample::class)) + * .encryptionKey(object : EncryptionKeyCallback { + * override fun keyPointer() = keyPointer + * override fun releaseKey() = unsafe.freeMemory(keyPointer) + * }) + * .build() + * + * val realm = Realm.open(encryptedConf) + *``` + */ + @OptIn(ExperimentalEncryptionCallbackApi::class) + public fun encryptionKey(encryptionKeyAsCallback: EncryptionKeyCallback): S = + apply { this.encryptionKeyAsCallback = encryptionKeyAsCallback } as S + /** * Sets a callback for controlling whether the realm should be compacted when opened. * diff --git a/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/RealmConfiguration.kt b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/RealmConfiguration.kt index 4b46f62364..a6ec2bc9ca 100644 --- a/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/RealmConfiguration.kt +++ b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/RealmConfiguration.kt @@ -16,6 +16,7 @@ package io.realm.kotlin +import io.realm.kotlin.annotations.ExperimentalEncryptionCallbackApi import io.realm.kotlin.internal.ContextLogger import io.realm.kotlin.internal.RealmConfigurationImpl import io.realm.kotlin.internal.platform.appFilesDirectory @@ -185,6 +186,8 @@ public interface RealmConfiguration : Configuration { writerDispatcherFactory, schemaVersion, encryptionKey, + @OptIn(ExperimentalEncryptionCallbackApi::class) + encryptionKeyAsCallback, deleteRealmIfMigrationNeeded, compactOnLaunchCallback, migration, diff --git a/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/annotations/ExperimentalEncryptionCallbackApi.kt b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/annotations/ExperimentalEncryptionCallbackApi.kt new file mode 100644 index 0000000000..9fd2f28325 --- /dev/null +++ b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/annotations/ExperimentalEncryptionCallbackApi.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2023 Realm Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package io.realm.kotlin.annotations + +/** + * This annotation mark Realm API for encryption callback **experimental**, i.e. + * there are no guarantees given that this API cannot change without warning between minor and + * major versions. They will not change between patch versions. + * + * For all other purposes these APIs are considered stable, i.e. they undergo the same testing + * as other parts of the API and should behave as documented with no bugs. It is primarily + * marked as experimental because we are unsure if this API provide value and solve the use + * cases that people have. If not, they will be changed or removed altogether. + */ +@MustBeDocumented +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.PROPERTY, + AnnotationTarget.FUNCTION, + AnnotationTarget.TYPEALIAS +) +@RequiresOptIn(level = RequiresOptIn.Level.ERROR) +public annotation class ExperimentalEncryptionCallbackApi diff --git a/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/ConfigurationImpl.kt b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/ConfigurationImpl.kt index 1c5fae2b73..39553fd280 100644 --- a/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/ConfigurationImpl.kt +++ b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/ConfigurationImpl.kt @@ -17,9 +17,11 @@ package io.realm.kotlin.internal import io.realm.kotlin.CompactOnLaunchCallback +import io.realm.kotlin.EncryptionKeyCallback import io.realm.kotlin.InitialDataCallback import io.realm.kotlin.InitialRealmFileConfiguration import io.realm.kotlin.LogConfiguration +import io.realm.kotlin.annotations.ExperimentalEncryptionCallbackApi import io.realm.kotlin.dynamic.DynamicMutableRealm import io.realm.kotlin.dynamic.DynamicMutableRealmObject import io.realm.kotlin.dynamic.DynamicRealm @@ -60,8 +62,10 @@ public open class ConfigurationImpl( schemaVersion: Long, schemaMode: SchemaMode, private val userEncryptionKey: ByteArray?, + @OptIn(ExperimentalEncryptionCallbackApi::class) + override val encryptionKeyAsCallback: EncryptionKeyCallback?, compactOnLaunchCallback: CompactOnLaunchCallback?, - private val userMigration: RealmMigration?, + userMigration: RealmMigration?, automaticBacklinkHandling: Boolean, initialDataCallback: InitialDataCallback?, override val isFlexibleSyncConfiguration: Boolean, @@ -230,6 +234,11 @@ public open class ConfigurationImpl( RealmInterop.realm_config_set_encryption_key(nativeConfig, key) } + @OptIn(ExperimentalEncryptionCallbackApi::class) + encryptionKeyAsCallback?.let { + RealmInterop.realm_config_set_encryption_key_from_pointer(nativeConfig, it.keyPointer()) + } + RealmInterop.realm_config_set_in_memory(nativeConfig, inMemory) nativeConfig diff --git a/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/RealmConfigurationImpl.kt b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/RealmConfigurationImpl.kt index 8b0e620e2d..079ad4b0bd 100644 --- a/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/RealmConfigurationImpl.kt +++ b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/RealmConfigurationImpl.kt @@ -17,10 +17,12 @@ package io.realm.kotlin.internal import io.realm.kotlin.CompactOnLaunchCallback +import io.realm.kotlin.EncryptionKeyCallback import io.realm.kotlin.InitialDataCallback import io.realm.kotlin.InitialRealmFileConfiguration import io.realm.kotlin.LogConfiguration import io.realm.kotlin.RealmConfiguration +import io.realm.kotlin.annotations.ExperimentalEncryptionCallbackApi import io.realm.kotlin.internal.interop.SchemaMode import io.realm.kotlin.internal.util.CoroutineDispatcherFactory import io.realm.kotlin.migration.RealmMigration @@ -40,6 +42,8 @@ internal class RealmConfigurationImpl( writeDispatcherFactory: CoroutineDispatcherFactory, schemaVersion: Long, encryptionKey: ByteArray?, + @OptIn(ExperimentalEncryptionCallbackApi::class) + encryptionKeyAsCallback: EncryptionKeyCallback?, override val deleteRealmIfMigrationNeeded: Boolean, compactOnLaunchCallback: CompactOnLaunchCallback?, migration: RealmMigration?, @@ -62,6 +66,8 @@ internal class RealmConfigurationImpl( false -> SchemaMode.RLM_SCHEMA_MODE_AUTOMATIC }, encryptionKey, + @OptIn(ExperimentalEncryptionCallbackApi::class) + encryptionKeyAsCallback, compactOnLaunchCallback, migration, automaticBacklinkHandling, diff --git a/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/RealmImpl.kt b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/RealmImpl.kt index 463d604eea..30f5338e47 100644 --- a/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/RealmImpl.kt +++ b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/RealmImpl.kt @@ -19,6 +19,7 @@ package io.realm.kotlin.internal import io.realm.kotlin.Configuration import io.realm.kotlin.MutableRealm import io.realm.kotlin.Realm +import io.realm.kotlin.annotations.ExperimentalEncryptionCallbackApi import io.realm.kotlin.dynamic.DynamicRealm import io.realm.kotlin.internal.dynamic.DynamicRealmImpl import io.realm.kotlin.internal.interop.ClassKey @@ -42,11 +43,12 @@ import kotlinx.atomicfu.AtomicRef import kotlinx.atomicfu.atomic import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.withIndex import kotlinx.coroutines.launch @@ -138,6 +140,24 @@ public class RealmImpl private constructor( } realmScope.launch { + @OptIn(ExperimentalEncryptionCallbackApi::class) + configuration.encryptionKeyAsCallback?.let { + // if we're using an encryption key as a callback, we preemptively open the notifier and writer Realm + // with the given configuration because the key might be deleted from memory after the Realm is open. + + // These touches the notifier and writer lazy initialised Realms to open them with the provided configuration. + awaitAll( + async(notificationScheduler.dispatcher) { + notifier.realm.version().version + }, + async(writeScheduler.dispatcher) { + writer.realm.version().version + } + ) + + it.releaseKey() + } + notifier.realmChanged().collect { removeInitialRealmReference() // Closing this reference might be done by the GC: @@ -270,7 +290,6 @@ public class RealmImpl private constructor( current = initialRealmReference.value?.uncheckedVersion(), active = versionTracker.versions() ) - return VersionInfo( main = mainVersions, notifier = notifier.versions(), diff --git a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/sync/SyncConfiguration.kt b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/sync/SyncConfiguration.kt index 6224e89df0..bd2984c53d 100644 --- a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/sync/SyncConfiguration.kt +++ b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/sync/SyncConfiguration.kt @@ -20,6 +20,7 @@ import io.realm.kotlin.LogConfiguration import io.realm.kotlin.MutableRealm import io.realm.kotlin.Realm import io.realm.kotlin.TypedRealm +import io.realm.kotlin.annotations.ExperimentalEncryptionCallbackApi import io.realm.kotlin.internal.ConfigurationImpl import io.realm.kotlin.internal.ContextLogger import io.realm.kotlin.internal.ObjectIdImpl @@ -565,6 +566,8 @@ public interface SyncConfiguration : Configuration { schemaVersion, SchemaMode.RLM_SCHEMA_MODE_ADDITIVE_DISCOVERED, encryptionKey, + @OptIn(ExperimentalEncryptionCallbackApi::class) + encryptionKeyAsCallback, compactOnLaunchCallback, null, // migration is not relevant for sync, false, // automatic backlink handling is not relevant for sync diff --git a/packages/test-base/build.gradle.kts b/packages/test-base/build.gradle.kts index e56161f7d7..4ec6e2a1d1 100644 --- a/packages/test-base/build.gradle.kts +++ b/packages/test-base/build.gradle.kts @@ -126,6 +126,13 @@ android { } } + externalNativeBuild { + cmake { + version = Versions.cmake + path = project.file("src/androidMain/cpp/CMakeLists.txt") + } + } + buildTypes { // LibraryBuildType is not minifiable, but the current dependency from test-sync doesn't // allow test-base to be configured as a library. To test test-base with minification diff --git a/packages/test-base/src/androidMain/cpp/CMakeLists.txt b/packages/test-base/src/androidMain/cpp/CMakeLists.txt new file mode 100644 index 0000000000..f3c11643d8 --- /dev/null +++ b/packages/test-base/src/androidMain/cpp/CMakeLists.txt @@ -0,0 +1 @@ +add_library(android_jni_test_helper SHARED android_jni_helper.cpp) diff --git a/packages/test-base/src/androidMain/cpp/android_jni_helper.cpp b/packages/test-base/src/androidMain/cpp/android_jni_helper.cpp new file mode 100644 index 0000000000..94eeb48cdb --- /dev/null +++ b/packages/test-base/src/androidMain/cpp/android_jni_helper.cpp @@ -0,0 +1,23 @@ +#include + +extern "C" { + +JNIEXPORT jlong JNICALL +Java_io_realm_kotlin_test_platform_PlatformUtils_nativeAllocateEncryptionKeyOnNativeMemory( + JNIEnv *env, jclass, jbyteArray byteArray) { + jsize arrayLength = env->GetArrayLength(byteArray); + jbyte *nativeArray = new jbyte[arrayLength]; + // Copy the contents of the Kotlin ByteArray to the native array + env->GetByteArrayRegion(byteArray, 0, arrayLength, nativeArray); + + // Return the address of the native array + return reinterpret_cast(nativeArray); +} + +JNIEXPORT void JNICALL +Java_io_realm_kotlin_test_platform_PlatformUtils_nativeFreeEncryptionKeyFromNativeMemory( + JNIEnv *env, jclass, jlong keyPtr) { + delete[] reinterpret_cast(keyPtr); +} + +} diff --git a/packages/test-base/src/androidMain/kotlin/io/realm/kotlin/test/platform/PlatformUtils.kt b/packages/test-base/src/androidMain/kotlin/io/realm/kotlin/test/platform/PlatformUtils.kt index cfc7d1f33f..f1d9540f21 100644 --- a/packages/test-base/src/androidMain/kotlin/io/realm/kotlin/test/platform/PlatformUtils.kt +++ b/packages/test-base/src/androidMain/kotlin/io/realm/kotlin/test/platform/PlatformUtils.kt @@ -25,6 +25,10 @@ import kotlin.io.path.absolutePathString import kotlin.time.Duration actual object PlatformUtils { + init { + System.loadLibrary("android_jni_test_helper") + } + @SuppressLint("NewApi") actual fun createTempDir(prefix: String, readOnly: Boolean): String { val dir: Path = Files.createTempDirectory("$prefix-android_tests") @@ -56,6 +60,20 @@ actual object PlatformUtils { } SystemClock.sleep(5000) // 5 seconds to give the GC some time to process } + + actual fun allocateEncryptionKeyOnNativeMemory(aesKey: ByteArray): Long { + // Note: the ByteBuffer is not guaranteed to be in native memory (it could use a backing array) + // use allocateDirect.hasArray() to find out. + // We use JNI for Android to create such native array. + return nativeAllocateEncryptionKeyOnNativeMemory(aesKey) + } + + actual fun freeEncryptionKeyFromNativeMemory(aesKeyPointer: Long) { + nativeFreeEncryptionKeyFromNativeMemory(aesKeyPointer) + } + + private external fun nativeAllocateEncryptionKeyOnNativeMemory(byteArray: ByteArray): Long + private external fun nativeFreeEncryptionKeyFromNativeMemory(pointer: Long) } // Allocs as much garbage as we can. Pass maxSize = 0 to use all available memory in the process. diff --git a/packages/test-base/src/commonMain/kotlin/io/realm/kotlin/test/platform/PlatformUtils.kt b/packages/test-base/src/commonMain/kotlin/io/realm/kotlin/test/platform/PlatformUtils.kt index ee6a77661d..93db00afa7 100644 --- a/packages/test-base/src/commonMain/kotlin/io/realm/kotlin/test/platform/PlatformUtils.kt +++ b/packages/test-base/src/commonMain/kotlin/io/realm/kotlin/test/platform/PlatformUtils.kt @@ -10,4 +10,17 @@ expect object PlatformUtils { fun sleep(duration: Duration) fun threadId(): ULong fun triggerGC() + + /** + * Allocate a 64 byte array in native memory that contains the encryption key to be used. + * + * @param aesKey the value of the byte array to be copied. + * @return the address pointer to the memory region allocated. + */ + fun allocateEncryptionKeyOnNativeMemory(aesKey: ByteArray): Long + + /** + * Zero-out and release a previously written encryption key from native memory. + */ + fun freeEncryptionKeyFromNativeMemory(aesKeyPointer: Long) } diff --git a/packages/test-base/src/commonTest/kotlin/io/realm/kotlin/test/common/EncryptionTests.kt b/packages/test-base/src/commonTest/kotlin/io/realm/kotlin/test/common/EncryptionTests.kt index 0377f0a106..78831e7892 100644 --- a/packages/test-base/src/commonTest/kotlin/io/realm/kotlin/test/common/EncryptionTests.kt +++ b/packages/test-base/src/commonTest/kotlin/io/realm/kotlin/test/common/EncryptionTests.kt @@ -16,15 +16,24 @@ */ package io.realm.kotlin.test.common +import io.realm.kotlin.EncryptionKeyCallback import io.realm.kotlin.Realm import io.realm.kotlin.RealmConfiguration +import io.realm.kotlin.annotations.ExperimentalEncryptionCallbackApi import io.realm.kotlin.entities.Sample import io.realm.kotlin.test.platform.PlatformUtils +import io.realm.kotlin.test.util.TestChannel +import io.realm.kotlin.test.util.receiveOrFail +import io.realm.kotlin.test.util.use +import kotlinx.atomicfu.atomic +import kotlinx.coroutines.runBlocking import kotlin.random.Random import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertTrue /** * This class contains all the Realm encryption integration tests that validate opening a Realm with an encryption key. @@ -122,4 +131,59 @@ class EncryptionTests { } } } + + @OptIn(ExperimentalEncryptionCallbackApi::class) + @Test + fun openEncryptedRealmWithEncryptionKeyCallback() = runBlocking { + val key: ByteArray = Random.nextBytes(64) + val keyPointer: Long = PlatformUtils.allocateEncryptionKeyOnNativeMemory(key) + + val keyPointerCallbackInvocation = atomic(0) + val releaseKeyCallbackInvoked = TestChannel() + + val encryptedConf = RealmConfiguration + .Builder( + schema = setOf(Sample::class) + ) + .directory(tmpDir) + .encryptionKey(object : EncryptionKeyCallback { + override fun keyPointer(): Long { + keyPointerCallbackInvocation.incrementAndGet() + return keyPointer + } + + override fun releaseKey() { + PlatformUtils.freeEncryptionKeyFromNativeMemory(keyPointer) + releaseKeyCallbackInvoked.trySend(true) + } + }) + .build() + + // Initializes an encrypted Realm + Realm.open(encryptedConf).use { + it.writeBlocking { + copyToRealm(Sample().apply { stringField = "Foo Bar" }) + } + } + + assertTrue(releaseKeyCallbackInvoked.receiveOrFail(), "Releasing the key should only be invoked once all the 3 Realms have been opened") + assertEquals(3, keyPointerCallbackInvocation.value, "Encryption key pointer should have been invoked 3 times (Frozen Realm, Notifier and Writer Realms)") + + val keyPointer2 = PlatformUtils.allocateEncryptionKeyOnNativeMemory(key) + val encryptedConf2 = RealmConfiguration + .Builder( + schema = setOf(Sample::class) + ) + .directory(tmpDir) + .encryptionKey(object : EncryptionKeyCallback { + override fun keyPointer() = keyPointer2 + override fun releaseKey() = PlatformUtils.freeEncryptionKeyFromNativeMemory(keyPointer2) + }) + .build() + + Realm.open(encryptedConf2).use { + val sample: Sample = it.query(Sample::class).find().first() + assertEquals("Foo Bar", sample.stringField) + } + } } diff --git a/packages/test-base/src/jvmMain/kotlin/io/realm/kotlin/test/platform/PlatformUtils.kt b/packages/test-base/src/jvmMain/kotlin/io/realm/kotlin/test/platform/PlatformUtils.kt index a20938ed2c..11efffc86d 100644 --- a/packages/test-base/src/jvmMain/kotlin/io/realm/kotlin/test/platform/PlatformUtils.kt +++ b/packages/test-base/src/jvmMain/kotlin/io/realm/kotlin/test/platform/PlatformUtils.kt @@ -16,6 +16,7 @@ package io.realm.kotlin.test.platform +import sun.misc.Unsafe import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths @@ -65,6 +66,29 @@ actual object PlatformUtils { actual fun threadId(): ULong = Thread.currentThread().id.toULong() + actual fun allocateEncryptionKeyOnNativeMemory(aesKey: ByteArray): Long { + @Suppress("DiscouragedPrivateApi") + val field = Unsafe::class.java.getDeclaredField("theUnsafe") + field.isAccessible = true + val unsafe: Unsafe = field.get(null) as Unsafe + + val keyPointer: Long = unsafe.allocateMemory(aesKey.size.toLong()) + for (i in aesKey.indices) { + unsafe.putByte(keyPointer + i, aesKey[i]) + } + + return keyPointer + } + + actual fun freeEncryptionKeyFromNativeMemory(aesKeyPointer: Long) { + @Suppress("DiscouragedPrivateApi") + val field = Unsafe::class.java.getDeclaredField("theUnsafe") + field.isAccessible = true + val unsafe: Unsafe = field.get(null) as Unsafe + + unsafe.freeMemory(aesKeyPointer) + } + @Suppress("ExplicitGarbageCollectionCall") actual fun triggerGC() { for (i in 1..30) { diff --git a/packages/test-base/src/nativeDarwin/kotlin/io/realm/kotlin/test/platform/PlatformUtils.kt b/packages/test-base/src/nativeDarwin/kotlin/io/realm/kotlin/test/platform/PlatformUtils.kt index 87cd82b932..e20ce5bf1b 100644 --- a/packages/test-base/src/nativeDarwin/kotlin/io/realm/kotlin/test/platform/PlatformUtils.kt +++ b/packages/test-base/src/nativeDarwin/kotlin/io/realm/kotlin/test/platform/PlatformUtils.kt @@ -17,11 +17,17 @@ package io.realm.kotlin.test.platform import io.realm.kotlin.test.util.Utils +import kotlinx.cinterop.ByteVarOf +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.ULongVar import kotlinx.cinterop.alloc +import kotlinx.cinterop.allocArray import kotlinx.cinterop.cValue import kotlinx.cinterop.memScoped import kotlinx.cinterop.ptr +import kotlinx.cinterop.set +import kotlinx.cinterop.toCPointer import kotlinx.cinterop.value import platform.posix.S_IRGRP import platform.posix.S_IROTH @@ -67,6 +73,24 @@ actual object PlatformUtils { } } + @ExperimentalForeignApi + actual fun allocateEncryptionKeyOnNativeMemory(aesKey: ByteArray): Long { + val byteArrayPointer: CPointer> = kotlinx.cinterop.nativeHeap.allocArray(64) + + for (i in 0 until 64) { + byteArrayPointer[i] = aesKey[i] + } + + return byteArrayPointer.rawValue.toLong() + } + + @ExperimentalForeignApi + actual fun freeEncryptionKeyFromNativeMemory(aesKeyPointer: Long) { + aesKeyPointer.toCPointer>()?.let { + kotlinx.cinterop.nativeHeap.free(it.rawValue) + } + } + actual fun triggerGC() { GC.collect() }