Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Encrypt FHIR engine database via SQLCipher #787

Merged
merged 26 commits into from
Nov 29, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e3f45e4
Import SQLCipher dependencies
stevenckngaa Sep 15, 2021
9e43cb9
Generate a 16 bytes key for database encryption
stevenckngaa Sep 15, 2021
b0d3388
Merge branch 'master' into ckng/db_encryption
stevenckngaa Sep 15, 2021
75a5006
Apply spotless
stevenckngaa Sep 15, 2021
5b03a8a
Allow database encryption on Android 6.0 or above
stevenckngaa Sep 20, 2021
4d1a969
Merge branch 'master' into ckng/db_encryption
stevenckngaa Sep 30, 2021
b44b9c8
Use a HMAC 256 signed empty string as the database key
stevenckngaa Sep 30, 2021
a9c1b94
Enable database encryption in the reference app by default
stevenckngaa Sep 30, 2021
ce43106
Load database key in RoomDatabase background thread
stevenckngaa Oct 8, 2021
98fa50d
Merge branch 'master' into ckng/db_encryption
stevenckngaa Oct 8, 2021
2746cc3
Add retry when Android keystore is busy
stevenckngaa Oct 18, 2021
a941caa
Reenable some tests
stevenckngaa Oct 18, 2021
d5ab15f
Merge branch 'master' into ckng/db_encryption
stevenckngaa Oct 27, 2021
bf9105c
Remove outdated TODO
stevenckngaa Oct 27, 2021
7335ec8
Use a different database name for unencrypted and encrypted database
stevenckngaa Oct 29, 2021
956f019
Update documentation
stevenckngaa Oct 29, 2021
68e0e52
Merge branch 'master' into ckng/db_encryption
stevenckngaa Nov 5, 2021
d7f7dc1
Move keystore timeout retry to SQLCipherSupportHelper
stevenckngaa Nov 5, 2021
e4d009b
Update kdoc
stevenckngaa Nov 5, 2021
6137ba6
Update documentation
stevenckngaa Nov 12, 2021
386808a
Merge branch 'master' into ckng/db_encryption
stevenckngaa Nov 12, 2021
d54b91c
Apply spotless
stevenckngaa Nov 12, 2021
73441e5
Allow SQLCipher license because it's effectively BSD-3
stevenckngaa Nov 15, 2021
00b2b9e
Fix SQLCipher license allowed dependency
stevenckngaa Nov 15, 2021
b00b47c
Merge branch 'master' into ckng/db_encryption
stevenckngaa Nov 26, 2021
650ac9a
Address Jing's comments
stevenckngaa Nov 26, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Move keystore timeout retry to SQLCipherSupportHelper
  • Loading branch information
stevenckngaa committed Nov 5, 2021
commit d7f7dc15d987062b5ee04b1072112037ab1c76a2
2 changes: 1 addition & 1 deletion buildSrc/src/main/kotlin/Dependencies.kt
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ object Dependencies {
const val jsonToolsPatch = "1.13"
const val material = "1.4.0"
const val retrofit = "2.7.2"
const val sqlcipher = "4.4.3"
const val sqlcipher = "4.5.0"
stevenckngaa marked this conversation as resolved.
Show resolved Hide resolved
const val truth = "1.0.1"
const val flexBox = "3.0.0"
const val kotlinPoet = "1.9.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,10 @@ class DatabaseImplTest {
}
private val context: Context = ApplicationProvider.getApplicationContext()
private val services =
if (encrypted) {
FhirServices.builder(context).inMemory().enableEncryption().build()
} else {
FhirServices.builder(context).inMemory().build()
}
FhirServices.builder(context)
.inMemory()
.apply { if (encrypted) enableEncryptionIfSupported() }
.build()
private val testingUtils = TestingUtils(services.parser)
private val database = services.database

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ object FhirEngineProvider {
private lateinit var fhirEngineConfiguration: FhirEngineConfiguration
private lateinit var fhirEngine: FhirEngine

/**
* Initializes the [FhirEngine] singleton with a custom Configuration.
*
* This method throws [IllegalStateException] if it is called multiple times
*/
@Synchronized
fun init(fhirEngineConfiguration: FhirEngineConfiguration) {
check(!FhirEngineProvider::fhirEngineConfiguration.isInitialized) {
Expand All @@ -35,17 +40,19 @@ object FhirEngineProvider {
/**
* Returns the cached [FhirEngine] instance. Creates a new instance from the supplied [Context] if
* it doesn't exist.
*
* If this method is called without calling [init], the default [FhirEngineConfiguration] is used.
*/
@Synchronized
fun getInstance(context: Context): FhirEngine {
if (!::fhirEngine.isInitialized) {
if (!::fhirEngineConfiguration.isInitialized) {
fhirEngineConfiguration = FhirEngineConfiguration(enableEncryption = false, UNSPECIFIED)
fhirEngineConfiguration = FhirEngineConfiguration()
}
fhirEngine =
FhirServices.builder(context.applicationContext)
.apply {
if (fhirEngineConfiguration.enableEncryption) enableEncryption()
if (fhirEngineConfiguration.enableEncryptionIfSupported) enableEncryptionIfSupported()
setDatabaseErrorStrategy(fhirEngineConfiguration.databaseErrorStrategy)
}
.build()
Expand All @@ -55,9 +62,10 @@ object FhirEngineProvider {
}
}

/** A configuration which describes the database setup and error recovery. */
data class FhirEngineConfiguration(
val enableEncryption: Boolean,
val databaseErrorStrategy: DatabaseErrorStrategy
val enableEncryptionIfSupported: Boolean = false,
val databaseErrorStrategy: DatabaseErrorStrategy = UNSPECIFIED
)

enum class DatabaseErrorStrategy {
Expand All @@ -71,7 +79,7 @@ enum class DatabaseErrorStrategy {
* If a database error occurs at open, automatically recreate the database.
*
* This strategy is NOT respected when opening a previously unencrypted database with an encrypted
* configuration or vice versa.
* configuration or vice versa. An [IllegalStateException] is thrown instead.
*/
RECREATE_AT_OPEN
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@
package com.google.android.fhir

import android.content.Context
import android.util.Log
import ca.uhn.fhir.context.FhirContext
import ca.uhn.fhir.parser.IParser
import com.google.android.fhir.db.Database
import com.google.android.fhir.db.impl.DatabaseConfig
import com.google.android.fhir.db.impl.DatabaseEncryptionKeyProvider.isDatabaseEncryptionSupported
import com.google.android.fhir.db.impl.DatabaseImpl
import com.google.android.fhir.impl.FhirEngineImpl
import java.lang.UnsupportedOperationException

internal data class FhirServices(
val fhirEngine: FhirEngine,
Expand All @@ -38,9 +38,10 @@ internal data class FhirServices(

internal fun inMemory() = apply { inMemory = true }

internal fun enableEncryption() = apply {
internal fun enableEncryptionIfSupported() = apply {
if (!isDatabaseEncryptionSupported()) {
throw UnsupportedOperationException("Database encryption isn't supported in this device.")
Log.w(TAG, "Database encryption isn't supported in this device.")
return this
}
enableEncryption = true
}
Expand All @@ -64,5 +65,6 @@ internal data class FhirServices(

companion object {
fun builder(context: Context) = Builder(context)
private const val TAG = "FhirService"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ val KeyStoreException.databaseEncryptionException: DatabaseEncryptionException
return DatabaseEncryptionException(this, UNKNOWN)
}

/**
* A list of keystore error. This is a duplicate of
* https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/security/keymaster/KeymasterDefs.java
*/
@Suppress("Unused")
object KeyStoreExceptionErrorCode {
stevenckngaa marked this conversation as resolved.
Show resolved Hide resolved
const val ERROR_OK = 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,39 +47,51 @@ internal class DatabaseImpl(
databaseConfig: DatabaseConfig
) : com.google.android.fhir.db.Database {

val builder =
when {
databaseConfig.inMemory -> Room.inMemoryDatabaseBuilder(context, ResourceDatabase::class.java)
databaseConfig.enableEncryption ->
Room.databaseBuilder(context, ResourceDatabase::class.java, ENCRYPTED_DATABASE_NAME)
else -> Room.databaseBuilder(context, ResourceDatabase::class.java, UNENCRYPTED_DATABASE_NAME)
}
val db: ResourceDatabase

init {
stevenckngaa marked this conversation as resolved.
Show resolved Hide resolved
if (databaseConfig.enableEncryption &&
DatabaseEncryptionKeyProvider.isDatabaseEncryptionSupported()
) {
builder.openHelperFactory {
SQLCipherSupportHelper(it, databaseErrorStrategy = databaseConfig.databaseErrorStrategy) {
DatabaseEncryptionKeyProvider.getOrCreatePassphrase(DATABASE_PASSPHRASE_NAME)
}
}
}
db = builder.build()
val enableEncryption =
databaseConfig.enableEncryption &&
DatabaseEncryptionKeyProvider.isDatabaseEncryptionSupported()

// The detection of unintentional switching of database encryption across releases can't be
// placed inside withTransaction because the database is opened within withTransaction. The
// default handling of corruption upon open in the room database is to re-create the database,
// which is undesirable.
val unexpectedDatabaseName = if (databaseConfig.enableEncryption) {
ENCRYPTED_DATABASE_NAME
} else {
UNENCRYPTED_DATABASE_NAME
}
val unexpectedDatabaseName =
if (enableEncryption) {
UNENCRYPTED_DATABASE_NAME
} else {
ENCRYPTED_DATABASE_NAME
}
check(!context.getDatabasePath(unexpectedDatabaseName).exists()) {
"Unexpected database, $unexpectedDatabaseName, has already existed. " +
"Check if you have accidentally enabled / disabled database encryption across releases."
}

@SuppressWarnings("NewApi")
db =
// Initializes builder with the database file name
when {
databaseConfig.inMemory ->
Room.inMemoryDatabaseBuilder(context, ResourceDatabase::class.java)
enableEncryption ->
Room.databaseBuilder(context, ResourceDatabase::class.java, ENCRYPTED_DATABASE_NAME)
else ->
Room.databaseBuilder(context, ResourceDatabase::class.java, UNENCRYPTED_DATABASE_NAME)
}
.apply {
// Provide the SupportSQLiteOpenHelper which enables the encryption.
if (enableEncryption) {
openHelperFactory {
SQLCipherSupportHelper(
it,
databaseErrorStrategy = databaseConfig.databaseErrorStrategy
) { DatabaseEncryptionKeyProvider.getOrCreatePassphrase(DATABASE_PASSPHRASE_NAME) }
}
}
}
.build()
}

private val resourceDao by lazy { db.resourceDao().also { it.iParser = iParser } }
Expand Down Expand Up @@ -185,8 +197,7 @@ internal class DatabaseImpl(
*/
const val ENCRYPTED_DATABASE_NAME = "encryptedFhirEngine"
stevenckngaa marked this conversation as resolved.
Show resolved Hide resolved

@VisibleForTesting
const val DATABASE_PASSPHRASE_NAME = "fhirEngine_db_passphrase"
@VisibleForTesting const val DATABASE_PASSPHRASE_NAME = "fhirEngine_db_passphrase"
stevenckngaa marked this conversation as resolved.
Show resolved Hide resolved
}
}

Expand All @@ -195,6 +206,3 @@ data class DatabaseConfig(
val enableEncryption: Boolean,
val databaseErrorStrategy: DatabaseErrorStrategy
)

private const val LOG_TAG = "Fhir-DatabaseImpl"

Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ import com.google.android.fhir.db.DatabaseEncryptionException
import com.google.android.fhir.db.DatabaseEncryptionException.DatabaseEncryptionErrorCode.TIMEOUT
import com.google.android.fhir.db.DatabaseEncryptionException.DatabaseEncryptionErrorCode.UNKNOWN
import com.google.android.fhir.db.impl.DatabaseImpl.Companion.UNENCRYPTED_DATABASE_NAME
import java.lang.Exception
import java.time.Duration
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import net.sqlcipher.database.SQLiteDatabase
import net.sqlcipher.database.SQLiteDatabaseHook
import net.sqlcipher.database.SQLiteException
import net.sqlcipher.database.SQLiteOpenHelper
import java.lang.Exception
import java.time.Duration

/** A [SupportSQLiteOpenHelper] which initializes a [SQLiteDatabase] with a passphrase. */
class SQLCipherSupportHelper(
Expand Down Expand Up @@ -88,28 +88,28 @@ class SQLCipherSupportHelper(
}
val key = runBlocking { getPassphraseWithRetry() }
return try {
standardHelper.getWritableDatabase(key)
} catch (ex: SQLiteException) {
if (databaseErrorStrategy == DatabaseErrorStrategy.RECREATE_AT_OPEN) {
Log.w(LOG_TAG, "Fail to open database. Recreating database.")
configuration.context.getDatabasePath(databaseName).delete()
standardHelper.getWritableDatabase(key)
} catch (ex: SQLiteException) {
if (databaseErrorStrategy == DatabaseErrorStrategy.RECREATE_AT_OPEN) {
Log.w(LOG_TAG, "Fail to open database. Recreating database.")
configuration.context.getDatabasePath(databaseName).delete()
standardHelper.getWritableDatabase(key)
} else {
throw ex
}
} else {
throw ex
}
}
}

private suspend fun getPassphraseWithRetry(): ByteArray {
var lastException: DatabaseEncryptionException? = null
var lastException: DatabaseEncryptionException? = null
for (retryAttempt in 1..MAX_RETRY_ATTEMPTS) {
try {
return passphraseFetcher()
} catch (exception: DatabaseEncryptionException) {
lastException = exception
if (exception.errorCode == TIMEOUT) {
Log.i(LOG_TAG, "Fail to get the encryption key on attempt: $retryAttempt")
delay(retryDelay.toMillis() * retryAttempt)
Log.i(LOG_TAG, "Fail to get the encryption key on attempt: $retryAttempt")
delay(retryDelay.toMillis() * retryAttempt)
} else {
throw exception
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,13 @@ class FhirApplication : Application() {

override fun onCreate() {
super.onCreate()
FhirEngineProvider.init(
FhirEngineConfiguration(enableEncryptionIfSupported = true, RECREATE_AT_OPEN)
)
Sync.oneTimeSync<FhirPeriodicSyncWorker>(this)
}

private fun constructFhirEngine(): FhirEngine {
FhirEngineProvider.init(FhirEngineConfiguration(enableEncryption = true, RECREATE_AT_OPEN))
return FhirEngineProvider.getInstance(this)
}

Expand Down