Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<ByteVarOf<Byte>>()
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)
Expand Down
6 changes: 6 additions & 0 deletions packages/jni-swig-stub/src/main/jni/realm_api_helpers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<realm_websocket_observer_t*>(observer_ptr), was_clean, static_cast<realm_web_socket_errno_e>(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<uint8_t*>(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*/
Expand Down
1 change: 1 addition & 0 deletions packages/jni-swig-stub/src/main/jni/realm_api_helpers.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,18 @@ public data class InitialRealmFileConfiguration(
val checksum: String?
)

public interface EncryptionKeyCallback {
/**
* Provides the native memory address of the 64 byte array containing the key used to encrypt and decrypt the Realm file.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would also document that this can be called multiple times and that release is only called once, so they do not accidentially create a new Pointer for every call to this, but only release it once.

This was also why I thought it might make a better API if this was an symmetric API, i.e. called 3 times, and released 3 times, but I do agree that the current behavior is easier to implement for the sake of an POC.

*/
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.
*/
public fun releaseKey()
}

/**
* Base configuration options shared between all realm configuration types.
*/
Expand Down Expand Up @@ -153,6 +165,13 @@ 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.
*/
public val encryptionKeyAsCallback: EncryptionKeyCallback?

/**
* Callback that determines if the realm file should be compacted as part of opening it.
*
Expand Down Expand Up @@ -234,6 +253,7 @@ public interface Configuration {
protected var writeDispatcher: CoroutineDispatcher? = null
protected var schemaVersion: Long = 0
protected var encryptionKey: ByteArray? = null
protected var encryptionKeyAsCallback: EncryptionKeyCallback? = null
protected var compactOnLaunchCallback: CompactOnLaunchCallback? = null
protected var initialDataCallback: InitialDataCallback? = null
protected var inMemory: Boolean = false
Expand Down Expand Up @@ -354,6 +374,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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great explanation 💯

* 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)
*```
*/
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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ public interface RealmConfiguration : Configuration {
writerDispatcherFactory,
schemaVersion,
encryptionKey,
encryptionKeyAsCallback,
deleteRealmIfMigrationNeeded,
compactOnLaunchCallback,
migration,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
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
Expand Down Expand Up @@ -60,8 +61,9 @@ public open class ConfigurationImpl(
schemaVersion: Long,
schemaMode: SchemaMode,
private val userEncryptionKey: ByteArray?,
override val encryptionKeyAsCallback: EncryptionKeyCallback?,
compactOnLaunchCallback: CompactOnLaunchCallback?,
private val userMigration: RealmMigration?,
userMigration: RealmMigration?,
automaticBacklinkHandling: Boolean,
initialDataCallback: InitialDataCallback?,
override val isFlexibleSyncConfiguration: Boolean,
Expand Down Expand Up @@ -230,6 +232,10 @@ public open class ConfigurationImpl(
RealmInterop.realm_config_set_encryption_key(nativeConfig, key)
}

encryptionKeyAsCallback?.let {
RealmInterop.realm_config_set_encryption_key_from_pointer(nativeConfig, it.keyPointer())
}

RealmInterop.realm_config_set_in_memory(nativeConfig, inMemory)

nativeConfig
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
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
Expand All @@ -40,6 +41,7 @@ internal class RealmConfigurationImpl(
writeDispatcherFactory: CoroutineDispatcherFactory,
schemaVersion: Long,
encryptionKey: ByteArray?,
encryptionKeyAsCallback: EncryptionKeyCallback?,
override val deleteRealmIfMigrationNeeded: Boolean,
compactOnLaunchCallback: CompactOnLaunchCallback?,
migration: RealmMigration?,
Expand All @@ -62,6 +64,7 @@ internal class RealmConfigurationImpl(
false -> SchemaMode.RLM_SCHEMA_MODE_AUTOMATIC
},
encryptionKey,
encryptionKeyAsCallback,
compactOnLaunchCallback,
migration,
automaticBacklinkHandling,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ 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
Expand Down Expand Up @@ -138,6 +137,20 @@ public class RealmImpl private constructor(
}

realmScope.launch {
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.
launch(notificationScheduler.dispatcher) {
notifier.realm.version().version
}
launch(writeScheduler.dispatcher) {
writer.realm.version().version
it.releaseKey()
}
}

notifier.realmChanged().collect {
removeInitialRealmReference()
// Closing this reference might be done by the GC:
Expand Down Expand Up @@ -270,7 +283,6 @@ public class RealmImpl private constructor(
current = initialRealmReference.value?.uncheckedVersion(),
active = versionTracker.versions()
)

return VersionInfo(
main = mainVersions,
notifier = notifier.versions(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,7 @@ public interface SyncConfiguration : Configuration {
schemaVersion,
SchemaMode.RLM_SCHEMA_MODE_ADDITIVE_DISCOVERED,
encryptionKey,
encryptionKeyAsCallback,
compactOnLaunchCallback,
null, // migration is not relevant for sync,
false, // automatic backlink handling is not relevant for sync
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ 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. Ideally we want to use JNI for Android to
// create such native array.
TODO()
}

actual fun freeEncryptionKeyFromNativeMemory(aesKeyPointer: Long) {
TODO()
}
}

// Allocs as much garbage as we can. Pass maxSize = 0 to use all available memory in the process.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,19 @@
*/
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.entities.Sample
import io.realm.kotlin.test.platform.PlatformUtils
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

/**
Expand Down Expand Up @@ -122,4 +127,58 @@ class EncryptionTests {
}
}
}

@Test
fun openEncryptedRealmWithEncryptionKeyCallback() = runBlocking {
val key: ByteArray = Random.nextBytes(64)
val keyPointer: Long = PlatformUtils.allocateEncryptionKeyOnNativeMemory(key)

val keyPointerCallbackInvocation = atomic(0)
val keyPointerReleaseCallbackInvocation = atomic(0)

val encryptedConf = RealmConfiguration
.Builder(
schema = setOf(Sample::class)
)
.directory(tmpDir)
.encryptionKey(object : EncryptionKeyCallback {
override fun keyPointer(): Long {
keyPointerCallbackInvocation.incrementAndGet()
return keyPointer
}

override fun releaseKey() {
keyPointerReleaseCallbackInvocation.incrementAndGet()
PlatformUtils.freeEncryptionKeyFromNativeMemory(keyPointer)
}
})
.build()

// Initializes an encrypted Realm
Realm.open(encryptedConf).use {
it.writeBlocking {
copyToRealm(Sample().apply { stringField = "Foo Bar" })
}
}

assertEquals(3, keyPointerCallbackInvocation.value, "Encryption key pointer should have been invoked 3 times (Frozen Realm, Notifier and Writer Realms)")
assertEquals(1, keyPointerReleaseCallbackInvocation.value, "Releasing the key should only be invoked once all the 3 Realms have been opened")

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)
}
}
}
Loading