diff --git a/pretixscan/app/build.gradle b/pretixscan/app/build.gradle index 73af530..293b4b4 100644 --- a/pretixscan/app/build.gradle +++ b/pretixscan/app/build.gradle @@ -127,6 +127,7 @@ dependencies { implementation 'androidx.recyclerview:recyclerview:1.2.1' implementation 'androidx.multidex:multidex:2.0.1' implementation "androidx.preference:preference-ktx:1.2.1" + implementation 'androidx.sqlite:sqlite:2.2.0' // 2.4.0 requires compileSDK 34 implementation 'com.louiscad.splitties:splitties-toast:3.0.0' implementation 'com.github.traex.rippleeffect:library:1.3' @@ -141,7 +142,7 @@ dependencies { implementation "io.requery:requery:$requery_version" implementation "io.requery:requery-android:$requery_version" implementation "io.requery:requery-kotlin:$requery_version" - implementation 'net.zetetic:android-database-sqlcipher:3.5.9' + implementation 'net.zetetic:sqlcipher-android:4.5.5' implementation "net.sourceforge.streamsupport:streamsupport-cfuture:$cfuture_version" implementation "com.fasterxml.jackson.core:jackson-databind:$jackson_core_version" implementation "com.fasterxml.jackson.module:jackson-module-kotlin:$jackson_kotlin_version" diff --git a/pretixscan/app/src/main/java/eu/pretix/pretixscan/droid/PretixScan.kt b/pretixscan/app/src/main/java/eu/pretix/pretixscan/droid/PretixScan.kt index 21c240f..7d493b1 100644 --- a/pretixscan/app/src/main/java/eu/pretix/pretixscan/droid/PretixScan.kt +++ b/pretixscan/app/src/main/java/eu/pretix/pretixscan/droid/PretixScan.kt @@ -15,12 +15,15 @@ import eu.pretix.libpretixsync.check.TicketCheckProvider import eu.pretix.libpretixsync.db.Migrations import eu.pretix.libpretixui.android.covid.DGC import eu.pretix.pretixscan.droid.connectivity.ConnectivityHelper +import eu.pretix.pretixscan.droid.db.SqlCipherDatabaseSource import eu.pretix.pretixscan.utils.KeystoreHelper import io.requery.BlockingEntityStore import io.requery.Persistable -import io.requery.android.sqlcipher.SqlCipherDatabaseSource import io.requery.android.sqlite.DatabaseSource import io.requery.sql.EntityDataStore +import net.zetetic.database.sqlcipher.SQLiteConnection +import net.zetetic.database.sqlcipher.SQLiteDatabase +import net.zetetic.database.sqlcipher.SQLiteDatabaseHook import java.util.concurrent.locks.ReentrantLock @@ -31,6 +34,23 @@ class PretixScan : MultiDexApplication() { var flipperInit: FlipperInitializer.IntializationResult? = null lateinit var connectivityHelper: ConnectivityHelper + private fun migrateSqlCipher(name: String, dbPass: String) { + System.loadLibrary("sqlcipher") + + val databaseFile = getDatabasePath(name) + SQLiteDatabase.openOrCreateDatabase(databaseFile, dbPass, null, null, object: SQLiteDatabaseHook { + override fun preKey(connection: SQLiteConnection) { + } + + override fun postKey(connection: SQLiteConnection) { + val result = connection.executeForLong("PRAGMA cipher_migrate;", emptyArray(), null) + if (result != 0L) { + throw SQLiteException("cipher_migrate failed") + } + } + }).close() + } + val data: BlockingEntityStore get() { if (dataStore == null) { @@ -44,19 +64,42 @@ class PretixScan : MultiDexApplication() { val dbPass = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) KeystoreHelper.secureValue(KEYSTORE_PASSWORD, true) else KEYSTORE_PASSWORD - var source = SqlCipherDatabaseSource(this, - Models.DEFAULT, Models.DEFAULT.getName(), dbPass, Migrations.CURRENT_VERSION) - source.setLoggingEnabled(false) + var source = SqlCipherDatabaseSource( + this, + Models.DEFAULT, + Models.DEFAULT.name, + dbPass, + Migrations.CURRENT_VERSION + ) try { // check if database has been decrypted - source.readableDatabase.rawQuery("select count(*) from sqlite_master;", emptyArray()) //source.getReadableDatabase().getSyncedTables() ??? + source.readableDatabase.rawQuery("select count(*) from sqlite_master;", emptyArray()) } catch (e: SQLiteException) { - // if not, delete it - this.deleteDatabase(Models.DEFAULT.getName()) - // and create a new one - source = SqlCipherDatabaseSource(this, - Models.DEFAULT, Models.DEFAULT.getName(), dbPass, Migrations.CURRENT_VERSION) + try { + source.close() + migrateSqlCipher(Models.DEFAULT.name, dbPass) + source = SqlCipherDatabaseSource( + this, + Models.DEFAULT, + Models.DEFAULT.name, + dbPass, + Migrations.CURRENT_VERSION + ) + source.readableDatabase.rawQuery("select count(*) from sqlite_master;", emptyArray()) + } catch (e: SQLiteException) { + // still not decrypted? then we probably lost the key due to a keystore issue + // let's start fresh, there's no reasonable other way to let the user out of this + this.deleteDatabase(Models.DEFAULT.getName()) + source = SqlCipherDatabaseSource( + this, + Models.DEFAULT, + Models.DEFAULT.name, + dbPass, + Migrations.CURRENT_VERSION + ) + } } + source.setLoggingEnabled(false) val configuration = source.configuration dataStore = EntityDataStore(configuration) diff --git a/pretixscan/app/src/main/java/eu/pretix/pretixscan/droid/db/SqlCipherConnection.kt b/pretixscan/app/src/main/java/eu/pretix/pretixscan/droid/db/SqlCipherConnection.kt new file mode 100644 index 0000000..c45d571 --- /dev/null +++ b/pretixscan/app/src/main/java/eu/pretix/pretixscan/droid/db/SqlCipherConnection.kt @@ -0,0 +1,153 @@ +/* + * Based on code + * Copyright 2019 requery.io + * + * 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 eu.pretix.pretixscan.droid.db + + +import android.database.sqlite.SQLiteConstraintException +import android.database.sqlite.SQLiteException +import io.requery.android.sqlite.BaseConnection +import net.zetetic.database.sqlcipher.SQLiteDatabase +import java.sql.DatabaseMetaData +import java.sql.PreparedStatement +import java.sql.ResultSet +import java.sql.SQLException +import java.sql.SQLFeatureNotSupportedException +import java.sql.SQLIntegrityConstraintViolationException +import java.sql.Statement + +/** + * [java.sql.Connection] implementation using SQLCipher SQLite Android API. + * + * @author Nikhil Purushe + */ +internal class SqlCipherConnection(val database: SQLiteDatabase) : BaseConnection() { + private val metaData: SqlCipherMetaData + private var enteredTransaction: Boolean = false + + init { + autoCommit = true + metaData = SqlCipherMetaData(this) + } + + override fun ensureTransaction() { + if (!autoCommit) { + if (!database.inTransaction()) { + database.beginTransaction() + enteredTransaction = true + } + } + } + + @Throws(SQLException::class) + override fun execSQL(sql: String) { + try { + database.execSQL(sql) + } catch (e: SQLiteException) { + throwSQLException(e) + } + + } + + @Throws(SQLException::class) + override fun commit() { + if (autoCommit) { + throw SQLException("commit called while in autoCommit mode") + } + if (database.inTransaction() && enteredTransaction) { + try { + database.setTransactionSuccessful() + } catch (e: IllegalStateException) { + throw SQLException(e) + } finally { + database.endTransaction() + enteredTransaction = false + } + } + } + + @Throws(SQLException::class) + override fun createStatement(): Statement { + return SqlCipherStatement(this) + } + + @Throws(SQLException::class) + override fun createStatement(resultSetType: Int, resultSetConcurrency: Int): Statement { + return createStatement(resultSetType, + resultSetConcurrency, ResultSet.HOLD_CURSORS_OVER_COMMIT) + } + + @Throws(SQLException::class) + override fun createStatement(resultSetType: Int, resultSetConcurrency: Int, + resultSetHoldability: Int): Statement { + if (resultSetConcurrency == ResultSet.CONCUR_UPDATABLE) { + throw SQLFeatureNotSupportedException("CONCUR_UPDATABLE not supported") + } + return SqlCipherStatement(this) + } + + @Throws(SQLException::class) + override fun getMetaData(): DatabaseMetaData { + return metaData + } + + @Throws(SQLException::class) + override fun isClosed(): Boolean { + return !database.isOpen() + } + + @Throws(SQLException::class) + override fun isReadOnly(): Boolean { + return database.isReadOnly() + } + + @Throws(SQLException::class) + override fun prepareStatement(sql: String, autoGeneratedKeys: Int): PreparedStatement { + return SqlCipherPreparedStatement(this, sql, autoGeneratedKeys) + } + + @Throws(SQLException::class) + override fun prepareStatement(sql: String, + resultSetType: Int, + resultSetConcurrency: Int, + resultSetHoldability: Int): PreparedStatement { + return SqlCipherPreparedStatement(this, sql, Statement.NO_GENERATED_KEYS) + } + + @Throws(SQLException::class) + override fun prepareStatement(sql: String, columnNames: Array): PreparedStatement { + return SqlCipherPreparedStatement(this, sql, Statement.RETURN_GENERATED_KEYS) + } + + @Throws(SQLException::class) + override fun rollback() { + if (autoCommit) { + throw SQLException("commit called while in autoCommit mode") + } + database.endTransaction() + } + + companion object { + @Throws(SQLException::class) + fun throwSQLException(exception: SQLiteException) { + if (exception is SQLiteConstraintException) { + throw SQLIntegrityConstraintViolationException(exception) + } + throw SQLException(exception) + } + } +} diff --git a/pretixscan/app/src/main/java/eu/pretix/pretixscan/droid/db/SqlCipherDatabaseSource.kt b/pretixscan/app/src/main/java/eu/pretix/pretixscan/droid/db/SqlCipherDatabaseSource.kt new file mode 100644 index 0000000..b364316 --- /dev/null +++ b/pretixscan/app/src/main/java/eu/pretix/pretixscan/droid/db/SqlCipherDatabaseSource.kt @@ -0,0 +1,125 @@ +/* + * Based on code + * Copyright 2019 requery.io + * + * 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 eu.pretix.pretixscan.droid.db + +import android.content.Context +import io.requery.android.DefaultMapping +import io.requery.android.LoggingListener +import io.requery.android.sqlite.DatabaseProvider +import io.requery.android.sqlite.SchemaUpdater +import io.requery.meta.EntityModel +import io.requery.sql.Configuration +import io.requery.sql.ConfigurationBuilder +import io.requery.sql.Mapping +import io.requery.sql.Platform +import io.requery.sql.SchemaModifier +import io.requery.sql.TableCreationMode +import io.requery.sql.platform.SQLite +import io.requery.util.function.Function +import net.zetetic.database.sqlcipher.SQLiteDatabase +import net.zetetic.database.sqlcipher.SQLiteOpenHelper +import java.sql.Connection + +open class SqlCipherDatabaseSource(context: Context, + private val model: EntityModel, + name: String, + password: String?, + version: Int) + : SQLiteOpenHelper(context, name, password, null, version, 0, null, null, true), DatabaseProvider { + + private val platform: Platform + private val mapping: Mapping + private var db: SQLiteDatabase? = null + private var _configuration: Configuration? = null + private var loggingEnabled: Boolean = false + private var mode: TableCreationMode? = null + + init { + this.platform = SQLite() + this.mapping = onCreateMapping(platform) + this.mode = TableCreationMode.CREATE_NOT_EXISTS + System.loadLibrary("sqlcipher"); + } + + override fun setLoggingEnabled(enable: Boolean) { + this.loggingEnabled = enable + } + + override fun setTableCreationMode(mode: TableCreationMode) { + this.mode = mode + } + + protected fun onCreateMapping(platform: Platform): Mapping { + return DefaultMapping(platform) + } + + protected fun onConfigure(builder: ConfigurationBuilder) { + if (loggingEnabled) { + val loggingListener = LoggingListener() + builder.addStatementListener(loggingListener) + } + } + + private fun getConnection(db: SQLiteDatabase): SqlCipherConnection { + synchronized(this) { + return SqlCipherConnection(db) + } + } + + override fun getConfiguration(): Configuration { + if (_configuration == null) { + val builder = ConfigurationBuilder(this, model) + .setMapping(mapping) + .setPlatform(platform) + .setBatchUpdateSize(1000) + onConfigure(builder) + _configuration = builder.build() + } + return _configuration!! + } + + override fun onCreate(db: SQLiteDatabase) { + this.db = db + SchemaModifier(configuration).createTables(TableCreationMode.CREATE) + } + + override fun onConfigure(db: SQLiteDatabase) {} + + override fun onOpen(db: SQLiteDatabase?) { + super.onOpen(db) + if (!db!!.isReadOnly) { + db.execSQL("PRAGMA foreign_keys = ON") + } + } + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + this.db = db + val updater = SchemaUpdater(configuration, + Function { s -> db.rawQuery(s, null) }, mode) + updater.update() + } + + override fun getConnection(): Connection { + synchronized(this) { + if (db == null) { + db = getWritableDatabase() + } + return getConnection(db!!) + } + } +} \ No newline at end of file diff --git a/pretixscan/app/src/main/java/eu/pretix/pretixscan/droid/db/SqlCipherMetaData.kt b/pretixscan/app/src/main/java/eu/pretix/pretixscan/droid/db/SqlCipherMetaData.kt new file mode 100644 index 0000000..16bd91f --- /dev/null +++ b/pretixscan/app/src/main/java/eu/pretix/pretixscan/droid/db/SqlCipherMetaData.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2018 requery.io + * + * 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 eu.pretix.pretixscan.droid.db + +import android.database.Cursor +import android.database.sqlite.SQLiteException +import io.requery.android.sqlite.BaseConnection +import io.requery.android.sqlite.SqliteMetaData +import io.requery.util.function.Function +import net.zetetic.database.sqlcipher.SQLiteDatabase +import java.io.Closeable + +import java.sql.SQLException + +internal class SqlCipherMetaData(connection: BaseConnection) : SqliteMetaData(connection) { + + @Throws(SQLException::class) + override fun queryMemory(function: Function, query: String): R { + try { + val database = SQLiteDatabase.openOrCreateDatabase(":memory:", "", null, null) + val cursor = database.rawQuery(query, null) + return function.apply(closeWithCursor(Closeable { database.close() }, cursor)) + } catch (e: SQLiteException) { + throw SQLException(e) + } + + } +} \ No newline at end of file diff --git a/pretixscan/app/src/main/java/eu/pretix/pretixscan/droid/db/SqlCipherPreparedStatement.kt b/pretixscan/app/src/main/java/eu/pretix/pretixscan/droid/db/SqlCipherPreparedStatement.kt new file mode 100644 index 0000000..8d3c788 --- /dev/null +++ b/pretixscan/app/src/main/java/eu/pretix/pretixscan/droid/db/SqlCipherPreparedStatement.kt @@ -0,0 +1,144 @@ +/* + * Copyright 2018 requery.io + * + * 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 eu.pretix.pretixscan.droid.db + + +import android.database.Cursor +import android.database.sqlite.SQLiteException +import io.requery.android.sqlite.BasePreparedStatement +import io.requery.android.sqlite.CursorResultSet +import io.requery.android.sqlite.SingleResultSet +import net.zetetic.database.sqlcipher.SQLiteStatement +import java.sql.ResultSet +import java.sql.SQLException +import java.sql.Statement + +/** + * [java.sql.PreparedStatement] implementation using Android's local SQLite database. + */ +internal class SqlCipherPreparedStatement @Throws(SQLException::class) +constructor(private val cipherConnection: SqlCipherConnection, sql: String, autoGeneratedKeys: Int) + : BasePreparedStatement(cipherConnection, sql, autoGeneratedKeys) { + + private val statement: SQLiteStatement = cipherConnection.database.compileStatement(sql) + private var cursor: Cursor? = null + + override fun bindNullOrString(index: Int, value: Any?) { + if (value == null) { + statement.bindNull(index) + bindings?.add(null) + } else { + val string = value.toString() + statement.bindString(index, string) + bindings?.add(string) + } + } + + override fun bindLong(index: Int, value: Long) { + statement.bindLong(index, value) + bindings?.add(value) + } + + override fun bindDouble(index: Int, value: Double) { + statement.bindDouble(index, value) + bindings?.add(value) + } + + override fun bindBlob(index: Int, value: ByteArray?) { + if (value == null) { + statement.bindNull(index) + bindings?.add(null) + } else { + statement.bindBlob(index, value) + if (bindings != null) { + bindBlobLiteral(index, value) + } + } + } + + @Throws(SQLException::class) + override fun close() { + clearParameters() + statement.close() + cursor?.close() + super.close() + } + + override fun execute(sql: String, autoGeneratedKeys: Int): Boolean { + throw UnsupportedOperationException() + } + + override fun executeQuery(sql: String): ResultSet { + throw UnsupportedOperationException() + } + + override fun executeUpdate(sql: String, autoGeneratedKeys: Int): Int { + throw UnsupportedOperationException() + } + + override fun clearParameters() { + statement.clearBindings() + bindings?.clear() + } + + @Throws(SQLException::class) + override fun execute(): Boolean { + try { + statement.execute() + } catch (e: SQLiteException) { + SqlCipherConnection.throwSQLException(e) + } + + return false + } + + @Throws(SQLException::class) + override fun executeQuery(): ResultSet? { + try { + val args = bindingsToArray() + cursor = cipherConnection.database.rawQuery(sql, args) + queryResult = CursorResultSet(this, cursor!!, false) + return queryResult + } catch (e: SQLiteException) { + SqlCipherConnection.throwSQLException(e) + } + + return null + } + + @Throws(SQLException::class) + override fun executeUpdate(): Int { + if (autoGeneratedKeys == Statement.RETURN_GENERATED_KEYS) { + try { + val rowId = statement.executeInsert() + insertResult = SingleResultSet(this, rowId) + updateCount = 1 + } catch (e: SQLiteException) { + SqlCipherConnection.throwSQLException(e) + } + + } else { + try { + updateCount = statement.executeUpdateDelete() + } catch (e: SQLiteException) { + SqlCipherConnection.throwSQLException(e) + } + + } + return updateCount + } +} \ No newline at end of file diff --git a/pretixscan/app/src/main/java/eu/pretix/pretixscan/droid/db/SqlCipherStatement.kt b/pretixscan/app/src/main/java/eu/pretix/pretixscan/droid/db/SqlCipherStatement.kt new file mode 100644 index 0000000..f30e5fd --- /dev/null +++ b/pretixscan/app/src/main/java/eu/pretix/pretixscan/droid/db/SqlCipherStatement.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2018 requery.io + * + * 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 eu.pretix.pretixscan.droid.db + +import android.annotation.SuppressLint +import android.database.sqlite.SQLiteException +import io.requery.android.sqlite.BaseStatement +import io.requery.android.sqlite.CursorResultSet +import io.requery.android.sqlite.SingleResultSet +import net.zetetic.database.sqlcipher.SQLiteStatement +import java.sql.ResultSet +import java.sql.SQLException +import java.sql.Statement + +/** + * [java.sql.Statement] implementation using Android's local SQLite database. + * + * @author Nikhil Purushe + */ +internal class SqlCipherStatement(protected val cipherConnection: SqlCipherConnection) + : BaseStatement(cipherConnection) { + + @Throws(SQLException::class) + override fun execute(sql: String, autoGeneratedKeys: Int): Boolean { + var statement: SQLiteStatement? = null + try { + statement = cipherConnection.database.compileStatement(sql) + if (autoGeneratedKeys == Statement.RETURN_GENERATED_KEYS) { + val rowId = statement!!.executeInsert() + insertResult = SingleResultSet(this, rowId) + return true + } else { + statement!!.execute() + } + } catch (e: SQLiteException) { + SqlCipherConnection.throwSQLException(e) + } finally { + statement?.close() + } + return false + } + + @Throws(SQLException::class) + override fun executeQuery(sql: String): ResultSet? { + try { + @SuppressLint("Recycle") // released with the queryResult + val cursor = cipherConnection.database.rawQuery(sql, null) + queryResult = CursorResultSet(this, cursor, true) + return queryResult + } catch (e: SQLiteException) { + SqlCipherConnection.throwSQLException(e) + } + + return null + } + + @Throws(SQLException::class) + override fun executeUpdate(sql: String, autoGeneratedKeys: Int): Int { + var statement: SQLiteStatement? = null + try { + statement = cipherConnection.database.compileStatement(sql) + if (autoGeneratedKeys == Statement.RETURN_GENERATED_KEYS) { + val rowId = statement!!.executeInsert() + insertResult = SingleResultSet(this, rowId) + return 1 + } else { + updateCount = statement!!.executeUpdateDelete() + return updateCount + } + } catch (e: SQLiteException) { + SqlCipherConnection.throwSQLException(e) + } finally { + statement?.close() + } + return 0 + } +} \ No newline at end of file