Add App List Encryption

This commit is contained in:
Jonas Lochmann 2022-07-25 02:00:00 +02:00
parent dc662a78f8
commit 62c83f045a
No known key found for this signature in database
GPG key ID: 8B8C9AEE10FA5B36
61 changed files with 4995 additions and 389 deletions

View file

@ -19,6 +19,7 @@ apply plugin: 'kotlin-android'
apply plugin: 'kotlin-parcelize'
apply plugin: "androidx.navigation.safeargs.kotlin"
apply plugin: 'kotlin-kapt'
apply plugin: 'com.squareup.wire'
android {
compileSdkVersion 33
@ -151,6 +152,10 @@ android {
}
}
wire {
kotlin {}
}
dependencies {
def nav_version = "2.5.0"
def room_version = "2.4.2"
@ -211,4 +216,6 @@ dependencies {
implementation 'org.whispersystems:curve25519-java:0.5.0'
implementation 'com.google.zxing:core:3.3.3'
api "com.squareup.wire:wire-runtime:4.4.0"
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,138 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.crypto
import java.nio.ByteBuffer
import java.security.SecureRandom
import javax.crypto.AEADBadTagException
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
object CryptContainer {
const val KEY_SIZE = 16
private const val AUTH_TAG_BITS = 128
private const val AUTH_TAG_BYTES = AUTH_TAG_BITS / 8
data class EncryptParameters(val generation: Long, val counter: Long, val key: ByteArray) {
companion object {
fun generate() = EncryptParameters(
key = generateKey(),
generation = 0,
counter = 0
)
}
}
data class Header (
val generation: Long,
val counter: Long,
val iv: Int
) {
companion object {
const val SIZE = 8 + 8 + 4
fun read(input: ByteArray): Header {
validate(input)
val buffer = ByteBuffer.wrap(input)
val generation = buffer.getLong(0)
val counter = buffer.getLong(8)
val iv = buffer.getInt(16)
return Header(
generation = generation,
counter = counter,
iv = iv
)
}
}
fun write(output: ByteBuffer) {
output.putLong(0, generation)
output.putLong(8, counter)
output.putInt(16, iv)
}
}
fun validate(input: ByteArray) {
if (input.size < Header.SIZE + AUTH_TAG_BYTES) throw CryptException.InvalidContainer()
}
fun generateKey() = ByteArray(KEY_SIZE).also { SecureRandom().nextBytes(it) }
private fun buildIV(counter: Long, iv: Int): ByteArray {
val result = ByteArray(12)
ByteBuffer.wrap(result).also {
it.putInt(0, iv)
it.putLong(4, counter)
}
return result
}
private fun buildSecretKey(key: ByteArray): SecretKeySpec {
if (key.size != KEY_SIZE) throw CryptException.InvalidKey()
return SecretKeySpec(key, "AES")
}
private fun buildAAD(generation: Long): ByteArray = ByteArray(8).also { result ->
ByteBuffer.wrap(result).putLong(0, generation)
}
fun decrypt(key: ByteArray, input: ByteArray): ByteArray {
val header = Header.read(input)
val cipher = Cipher.getInstance("AES/GCM/NoPadding").also {
it.init(Cipher.DECRYPT_MODE, buildSecretKey(key), GCMParameterSpec(AUTH_TAG_BITS, buildIV(header.counter, header.iv)))
it.updateAAD(buildAAD(header.generation))
}
try {
return cipher.doFinal(input, Header.SIZE, input.size - Header.SIZE)
} catch (ex: AEADBadTagException) {
throw CryptException.WrongKey()
}
}
fun encrypt(input: ByteArray, params: EncryptParameters): ByteArray {
val iv = SecureRandom().nextInt()
val cipher = Cipher.getInstance("AES/GCM/NoPadding").also {
it.init(Cipher.ENCRYPT_MODE, buildSecretKey(params.key), GCMParameterSpec(AUTH_TAG_BITS, buildIV(params.counter, iv)))
it.updateAAD(buildAAD(params.generation))
}
val result = ByteArray(Header.SIZE + input.size + AUTH_TAG_BYTES)
val buffer = ByteBuffer.wrap(result)
Header(
iv = iv,
counter = params.counter,
generation = params.generation
).write(buffer)
if (cipher.doFinal(input, 0, input.size, result, Header.SIZE) != input.size + AUTH_TAG_BYTES) {
throw IllegalStateException()
}
return result
}
}

View file

@ -53,6 +53,9 @@ object Curve25519 {
fun sign(privateKey: ByteArray, message: ByteArray): ByteArray = instance.calculateSignature(privateKey, message)
fun validateSignature(publicKey: ByteArray, message: ByteArray, signature: ByteArray) = instance.verifySignature(publicKey, message, signature)
// 32 bytes
fun sharedSecret(publicKey: ByteArray, privateKey: ByteArray) = instance.calculateAgreement(publicKey, privateKey)
}
fun Curve25519KeyPair.serialize(): ByteArray {

View file

@ -0,0 +1,23 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.crypto
sealed class CryptException: RuntimeException() {
class InvalidContainer: CryptException()
class InvalidKey: CryptException()
class WrongKey: CryptException()
}

View file

@ -41,6 +41,10 @@ interface Database {
fun categoryNetworkId(): CategoryNetworkIdDao
fun childTasks(): ChildTaskDao
fun timeWarning(): CategoryTimeWarningDao
fun cryptContainer(): CryptContainerDao
fun cryptContainerKeyRequest(): CryptContainerKeyRequestDao
fun cryptContainerKeyResult(): CryptContainerKeyResultDao
fun deviceKey(): DeviceKeyDao
fun <T> runInTransaction(block: () -> T): T
fun <T> runInUnobservedTransaction(block: () -> T): T

View file

@ -299,6 +299,25 @@ object DatabaseMigrations {
}
}
private val MIGRATE_TP_V43 = object: Migration(42, 43) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS `crypt_container_metadata` (`crypt_container_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `device_id` TEXT, `category_id` TEXT, `type` INTEGER NOT NULL, `server_version` TEXT NOT NULL, `current_generation` INTEGER NOT NULL, `current_generation_first_timestamp` INTEGER NOT NULL, `next_counter` INTEGER NOT NULL, `current_generation_key` BLOB, `status` INTEGER NOT NULL, FOREIGN KEY(`device_id`) REFERENCES `device`(`id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )")
database.execSQL("CREATE INDEX IF NOT EXISTS `index_crypt_container_metadata_device_id` ON `crypt_container_metadata` (`device_id`)")
database.execSQL("CREATE INDEX IF NOT EXISTS `index_crypt_container_metadata_category_id` ON `crypt_container_metadata` (`category_id`)")
database.execSQL("CREATE TABLE IF NOT EXISTS `crypt_container_data` (`crypt_container_id` INTEGER NOT NULL, `encrypted_data` BLOB NOT NULL, PRIMARY KEY(`crypt_container_id`), FOREIGN KEY(`crypt_container_id`) REFERENCES `crypt_container_metadata`(`crypt_container_id`) ON UPDATE CASCADE ON DELETE CASCADE )")
database.execSQL("CREATE TABLE IF NOT EXISTS `crypt_container_pending_key_request` (`crypt_container_id` INTEGER NOT NULL, `request_time_crypt_container_generation` INTEGER NOT NULL, `request_sequence_id` INTEGER NOT NULL, `request_key` BLOB NOT NULL, PRIMARY KEY(`crypt_container_id`), FOREIGN KEY(`crypt_container_id`) REFERENCES `crypt_container_metadata`(`crypt_container_id`) ON UPDATE CASCADE ON DELETE CASCADE )")
database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_crypt_container_pending_key_request_request_sequence_id` ON `crypt_container_pending_key_request` (`request_sequence_id`)")
database.execSQL("CREATE TABLE IF NOT EXISTS `crypt_container_key_result` (`request_sequence_id` INTEGER NOT NULL, `device_id` TEXT NOT NULL, `status` INTEGER NOT NULL, PRIMARY KEY(`request_sequence_id`, `device_id`), FOREIGN KEY(`request_sequence_id`) REFERENCES `crypt_container_pending_key_request`(`request_sequence_id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`device_id`) REFERENCES `device`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )")
database.execSQL("CREATE INDEX IF NOT EXISTS `index_crypt_container_key_result_request_sequence_id` ON `crypt_container_key_result` (`request_sequence_id`)")
database.execSQL("CREATE INDEX IF NOT EXISTS `index_crypt_container_key_result_device_id` ON `crypt_container_key_result` (`device_id`)")
database.execSQL("CREATE TABLE IF NOT EXISTS `device_public_key` (`device_id` TEXT NOT NULL, `public_key` BLOB NOT NULL, `next_sequence_number` INTEGER NOT NULL, PRIMARY KEY(`device_id`), FOREIGN KEY(`device_id`) REFERENCES `device`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )")
}
}
val ALL = arrayOf(
MIGRATE_TO_V2,
MIGRATE_TO_V3,
@ -340,6 +359,7 @@ object DatabaseMigrations {
MIGRATE_TO_V39,
MIGRATE_TO_V40,
MIGRATE_TO_V41,
MIGRATE_TO_V42
MIGRATE_TO_V42,
MIGRATE_TP_V43
)
}

View file

@ -52,8 +52,13 @@ import java.util.concurrent.TimeUnit
UserLimitLoginCategory::class,
CategoryNetworkId::class,
ChildTask::class,
CategoryTimeWarning::class
], version = 42)
CategoryTimeWarning::class,
CryptContainerMetadata::class,
CryptContainerData::class,
CryptContainerPendingKeyRequest::class,
CryptContainerKeyResult::class,
DevicePublicKey::class
], version = 43)
abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database {
companion object {
private val lock = Object()

View file

@ -66,7 +66,7 @@ abstract class CategoryDao {
abstract fun updateCategoryTemporarilyBlocked(categoryId: String, blocked: Boolean, endTime: Long)
@Query("SELECT id, base_version, apps_version, rules_version, usedtimes_version, tasks_version FROM category")
abstract fun getCategoriesWithVersionNumbers(): LiveData<List<CategoryWithVersionNumbers>>
abstract fun getCategoriesWithVersionNumbersSybc(): List<CategoryWithVersionNumbers>
@Query("UPDATE category SET apps_version = :assignedAppsVersion WHERE id = :categoryId")
abstract fun updateCategoryAssignedAppsVersion(categoryId: String, assignedAppsVersion: String)

View file

@ -25,6 +25,8 @@ import io.timelimit.android.data.model.ConfigurationItem
import io.timelimit.android.data.model.ConfigurationItemType
import io.timelimit.android.data.model.ConfigurationItemTypeConverter
import io.timelimit.android.data.model.ConfigurationItemTypeUtil
import io.timelimit.android.extensions.base64
import io.timelimit.android.extensions.parseBase64
import io.timelimit.android.extensions.toJsonReader
import io.timelimit.android.livedata.ignoreUnchanged
import io.timelimit.android.livedata.map
@ -86,28 +88,16 @@ abstract class ConfigDao {
updateValueSync(ConfigurationItemType.OwnDeviceId, deviceId)
}
fun getDeviceListVersion(): LiveData<String> {
return getValueOfKeyAsync(ConfigurationItemType.DeviceListVersion).map {
if (it == null) {
""
} else {
it
}
}
fun getDeviceListVersionSync(): String {
return getValueOfKeySync(ConfigurationItemType.DeviceListVersion) ?: ""
}
fun setDeviceListVersionSync(deviceListVersion: String) {
updateValueSync(ConfigurationItemType.DeviceListVersion, deviceListVersion)
}
fun getUserListVersion(): LiveData<String> {
return getValueOfKeyAsync(ConfigurationItemType.UserListVersion).map {
if (it == null) {
""
} else {
it
}
}
fun getUserListVersionSync(): String {
return getValueOfKeySync(ConfigurationItemType.UserListVersion) ?: ""
}
fun setUserListVersionSync(userListVersion: String) {
@ -354,4 +344,23 @@ abstract class ConfigDao {
(getConsentFlagsSync() and (flags.inv())).toString(16)
)
}
fun getSigningKeySync() = getValueOfKeySync(ConfigurationItemType.SigningKey)?.parseBase64()
fun getSigningKeyAsync() = getValueOfKeyAsync(ConfigurationItemType.SigningKey).map { v -> v?.let { Base64.decode(it, 0) } }
fun setSigningKeySync(value: ByteArray) = updateValueSync(ConfigurationItemType.SigningKey, value.base64())
private fun getNextSigningSequenceNumberSync(): Long = getValueOfKeySync(ConfigurationItemType.SignSequenceNumber)?.toLong() ?: 0L
private fun setNextSigningSequenceNumberSync(value: Long) = updateValueSync(ConfigurationItemType.SignSequenceNumber, value.toString())
fun getNextSigningSequenceNumberAndIncrementIt(): Long {
val current = getNextSigningSequenceNumberSync()
setNextSigningSequenceNumberSync(current + 1)
return current
}
fun getLastServerKeyRequestSequenceSync(): Long? = getValueOfKeySync(ConfigurationItemType.LastServerKeyRequestSequence)?.toLong()
fun setLastServerKeyRequestSequenceSync(value: Long) = updateValueSync(ConfigurationItemType.LastServerKeyRequestSequence, value.toString())
fun getLastServerKeyResponseSequenceSync(): Long? = getValueOfKeySync(ConfigurationItemType.LastKeyResponseSequence)?.toLong()
fun setLastServerKeyResponseSequenceSync(value: Long) = updateValueSync(ConfigurationItemType.LastKeyResponseSequence, value.toString())
}

View file

@ -0,0 +1,78 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.data.dao
import androidx.room.*
import io.timelimit.android.data.model.CryptContainerData
import io.timelimit.android.data.model.CryptContainerMetadata
import io.timelimit.android.data.model.CryptContainerMetadataProcessingStatusConverter
@Dao
@TypeConverters(CryptContainerMetadataProcessingStatusConverter::class)
interface CryptContainerDao {
@Query("SELECT * FROM crypt_container_metadata WHERE crypt_container_id = :containerId")
fun getCryptoMetadataSyncByContainerId(containerId: Long): CryptContainerMetadata?
@Query("SELECT * FROM crypt_container_metadata JOIN crypt_container_data USING (crypt_container_id) WHERE category_id IS NULL AND device_id = :deviceId AND type = :type")
fun getCryptoFullDataSyncByDeviceId(deviceId: String, type: Int): MetadataAndContent?
@Query("SELECT * FROM crypt_container_metadata WHERE category_id IS NULL AND device_id IS NULL AND type = :type")
fun getCryptoMetadataSyncByType(type: Int): CryptContainerMetadata?
@Query("SELECT * FROM crypt_container_metadata WHERE category_id IS NULL AND device_id = :deviceId AND type = :type")
fun getCryptoMetadataSyncByDeviceId(deviceId: String, type: Int): CryptContainerMetadata?
@Query("SELECT * FROM crypt_container_metadata WHERE category_id = :categoryId AND device_id IS NULL AND type = :type")
fun getCryptoMetadataSyncByCategoryId(categoryId: String, type: Int): CryptContainerMetadata?
@Query("DELETE FROM crypt_container_metadata WHERE category_id IS NULL AND device_id = :deviceId AND type in (:types)")
fun removeDeviceCryptoMetadata(deviceId: String, types: List<Int>)
@Query("SELECT * FROM crypt_container_metadata WHERE status = :processingStatus")
fun getMetadataByProcessingStatus(processingStatus: CryptContainerMetadata.ProcessingStatus): List<CryptContainerMetadata>
@Insert
fun insertMetadata(container: CryptContainerMetadata): Long
@Update
fun updateMetadata(container: List<CryptContainerMetadata>)
@Update
fun updateMetadata(container: CryptContainerMetadata)
@Insert
fun insertData(data: CryptContainerData)
@Update
fun updateData(data: List<CryptContainerData>)
@Update
fun updateData(data: CryptContainerData)
@Query("SELECT * FROM crypt_container_data WHERE crypt_container_id = :containerId")
fun getData(containerId: Long): CryptContainerData?
@Query("UPDATE crypt_container_metadata SET server_version = ''")
fun deleteAllServerVersionNumbers()
@Entity
data class MetadataAndContent(
@Embedded
val metadata: CryptContainerMetadata,
@ColumnInfo(name = "encrypted_data")
val encryptedData: ByteArray
)
}

View file

@ -0,0 +1,40 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.data.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import io.timelimit.android.data.model.CryptContainerPendingKeyRequest
@Dao
interface CryptContainerKeyRequestDao {
@Query("SELECT * FROM crypt_container_pending_key_request WHERE crypt_container_id = :cryptContainerId")
fun byCryptContainerId(cryptContainerId: Long): CryptContainerPendingKeyRequest?
@Query("SELECT * FROM crypt_container_pending_key_request WHERE request_sequence_id = :requestId")
fun byRequestId(requestId: Long): CryptContainerPendingKeyRequest?
@Insert
fun insert(item: CryptContainerPendingKeyRequest)
@Delete
fun delete(item: CryptContainerPendingKeyRequest)
@Query("DELETE FROM crypt_container_pending_key_request")
fun deleteAll()
}

View file

@ -0,0 +1,30 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.data.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import io.timelimit.android.data.model.CryptContainerKeyResult
@Dao
interface CryptContainerKeyResultDao {
@Query("SELECT COUNT(*) FROM crypt_container_key_result WHERE request_sequence_id = :requestSequenceNumber AND device_id = :deviceId")
fun countResultItems(requestSequenceNumber: Long, deviceId: String): Long
@Insert
fun insert(item: CryptContainerKeyResult)
}

View file

@ -55,7 +55,7 @@ abstract class DeviceDao {
abstract fun updateDeviceDefaultUser(deviceId: String, defaultUserId: String)
@Query("SELECT id, apps_version FROM device")
abstract fun getInstalledAppsVersions(): LiveData<List<DeviceWithAppVersion>>
abstract fun getInstalledAppsVersionsSync(): List<DeviceWithAppVersion>
@Query("DELETE FROM device WHERE id IN (:deviceIds)")
abstract fun removeDevicesById(deviceIds: List<String>)
@ -92,6 +92,14 @@ abstract class DeviceDao {
@Query("SELECT COUNT(*) FROM device JOIN user ON (device.current_user_id = user.id) WHERE user.type = \"child\"")
abstract fun countDevicesWithChildUser(): LiveData<Long>
fun getDeviceDetailDataSync() = getDeviceDetailDataSyncInternal(
CryptContainerMetadata.TYPE_APP_LIST_BASE,
CryptContainerMetadata.TYPE_APP_LIST_DIFF
)
@Query("SELECT d.id AS device_id, c1.server_version AS app_base_version, c2.server_version AS app_diff_version FROM device d LEFT JOIN crypt_container_metadata c1 ON (c1.device_id = d.id AND c1.type = :baseType) LEFT JOIN crypt_container_metadata c2 ON (c2.device_id = d.id AND c2.type = :diffType)")
protected abstract fun getDeviceDetailDataSyncInternal(baseType: Int, diffType: Int): List<DeviceDetailDataBase>
}
data class DeviceWithAppVersion(
@ -100,3 +108,12 @@ data class DeviceWithAppVersion(
@ColumnInfo(name = "apps_version")
val installedAppsVersions: String
)
data class DeviceDetailDataBase(
@ColumnInfo(name = "device_id")
val deviceId: String,
@ColumnInfo(name = "app_base_version")
val appBaseVersion: String?,
@ColumnInfo(name = "app_diff_version")
val appDiffVersion: String?
)

View file

@ -0,0 +1,38 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.data.dao
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import io.timelimit.android.data.model.DevicePublicKey
@Dao
interface DeviceKeyDao {
@Insert
fun insert(key: DevicePublicKey)
@Update
fun update(key: DevicePublicKey)
@Query("SELECT * FROM device_public_key WHERE device_id = :deviceId")
fun getSync(deviceId: String): DevicePublicKey?
@Query("SELECT * FROM device_public_key WHERE device_id = :deviceId")
fun getLive(deviceId: String): LiveData<DevicePublicKey?>
}

View file

@ -74,8 +74,6 @@ data class ConfigurationItem(
}
}
// TODO: validate config item values
enum class ConfigurationItemType {
OwnDeviceId,
UserListVersion,
@ -102,6 +100,10 @@ enum class ConfigurationItemType {
ServerApiLevel,
AnnoyManualUnblockCounter,
ConsentFlags,
SigningKey,
SignSequenceNumber,
LastServerKeyRequestSequence,
LastKeyResponseSequence
}
object ConfigurationItemTypeUtil {
@ -130,6 +132,10 @@ object ConfigurationItemTypeUtil {
private const val SERVER_API_LEVEL = 24
private const val ANNOY_MANUAL_UNBLOCK_COUNTER = 25
private const val CONSENT_FLAGS = 26
private const val SIGNING_KEY = 27
private const val SIGN_SEQUENCE_NUMBER = 28
private const val LAST_SERVER_KEY_REQUEST_SEQUENCE = 29
private const val LAST_SERVER_KEY_RESPONSE_SEQUENCE = 30
val TYPES = listOf(
ConfigurationItemType.OwnDeviceId,
@ -156,7 +162,11 @@ object ConfigurationItemTypeUtil {
ConfigurationItemType.CustomOrganizationName,
ConfigurationItemType.ServerApiLevel,
ConfigurationItemType.AnnoyManualUnblockCounter,
ConfigurationItemType.ConsentFlags
ConfigurationItemType.ConsentFlags,
ConfigurationItemType.SigningKey,
ConfigurationItemType.SignSequenceNumber,
ConfigurationItemType.LastServerKeyRequestSequence,
ConfigurationItemType.LastKeyResponseSequence
)
fun serialize(value: ConfigurationItemType) = when(value) {
@ -185,6 +195,10 @@ object ConfigurationItemTypeUtil {
ConfigurationItemType.ServerApiLevel -> SERVER_API_LEVEL
ConfigurationItemType.AnnoyManualUnblockCounter -> ANNOY_MANUAL_UNBLOCK_COUNTER
ConfigurationItemType.ConsentFlags -> CONSENT_FLAGS
ConfigurationItemType.SigningKey -> SIGNING_KEY
ConfigurationItemType.SignSequenceNumber -> SIGN_SEQUENCE_NUMBER
ConfigurationItemType.LastServerKeyRequestSequence -> LAST_SERVER_KEY_REQUEST_SEQUENCE
ConfigurationItemType.LastKeyResponseSequence -> LAST_SERVER_KEY_RESPONSE_SEQUENCE
}
fun parse(value: Int) = when(value) {
@ -213,6 +227,10 @@ object ConfigurationItemTypeUtil {
SERVER_API_LEVEL -> ConfigurationItemType.ServerApiLevel
ANNOY_MANUAL_UNBLOCK_COUNTER -> ConfigurationItemType.AnnoyManualUnblockCounter
CONSENT_FLAGS -> ConfigurationItemType.ConsentFlags
SIGNING_KEY -> ConfigurationItemType.SigningKey
SIGN_SEQUENCE_NUMBER -> ConfigurationItemType.SignSequenceNumber
LAST_SERVER_KEY_REQUEST_SEQUENCE -> ConfigurationItemType.LastServerKeyRequestSequence
LAST_SERVER_KEY_RESPONSE_SEQUENCE -> ConfigurationItemType.LastKeyResponseSequence
else -> throw IllegalArgumentException()
}
}
@ -256,6 +274,7 @@ object ExperimentalFlags {
// const val INSTANCE_ID_FG_APP_DETECTION = 65536L
// private const val OBSOLETE_DISABLE_FG_APP_DETECTION_FALLBACK = 131072L
const val STRICT_OVERLAY_CHECKING = 0x40000L
const val DISABLE_LEGACY_APP_SENDING = 0x80000L
}
object ConsentFlags {

View file

@ -0,0 +1,41 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.data.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@Entity(
tableName = "crypt_container_data",
foreignKeys = [
ForeignKey(
entity = CryptContainerMetadata::class,
childColumns = ["crypt_container_id"],
parentColumns = ["crypt_container_id"],
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE
)
]
)
data class CryptContainerData (
@PrimaryKey
@ColumnInfo(name = "crypt_container_id")
val cryptContainerId: Long,
@ColumnInfo(name = "encrypted_data")
val encryptedData: ByteArray
)

View file

@ -0,0 +1,64 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.data.model
import androidx.room.*
@Entity(
tableName = "crypt_container_key_result",
primaryKeys = ["request_sequence_id", "device_id"],
foreignKeys = [
ForeignKey(
entity = CryptContainerPendingKeyRequest::class,
childColumns = ["request_sequence_id"],
parentColumns = ["request_sequence_id"],
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = Device::class,
childColumns = ["device_id"],
parentColumns = ["id"],
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE
)
]
)
@TypeConverters(CryptContainerKeyResultStatusConverter::class)
data class CryptContainerKeyResult (
@ColumnInfo(name = "request_sequence_id", index = true)
val requestSequenceId: Long,
@ColumnInfo(name = "device_id", index = true)
val deviceId: String,
val status: Status
) {
enum class Status {
InvalidKey
}
}
class CryptContainerKeyResultStatusConverter {
@TypeConverter
fun toStatus(input: Int): CryptContainerKeyResult.Status = when (input) {
0 -> CryptContainerKeyResult.Status.InvalidKey
else -> CryptContainerKeyResult.Status.InvalidKey
}
@TypeConverter
fun toInt(input: CryptContainerKeyResult.Status): Int = when (input) {
CryptContainerKeyResult.Status.InvalidKey -> 0
}
}

View file

@ -0,0 +1,163 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.data.model
import androidx.room.*
import io.timelimit.android.crypto.CryptContainer
@Entity(
tableName = "crypt_container_metadata",
foreignKeys = [
ForeignKey(
entity = Device::class,
childColumns = ["device_id"],
parentColumns = ["id"],
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE
),
ForeignKey(
entity = Category::class,
childColumns = ["category_id"],
parentColumns = ["id"],
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE
)
]
)
@TypeConverters(CryptContainerMetadataProcessingStatusConverter::class)
data class CryptContainerMetadata (
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "crypt_container_id")
val cryptContainerId: Long,
@ColumnInfo(name = "device_id", index = true)
val deviceId: String?,
@ColumnInfo(name = "category_id", index = true)
val categoryId: String?,
val type: Int,
@ColumnInfo(name = "server_version")
val serverVersion: String,
@ColumnInfo(name = "current_generation")
val currentGeneration: Long,
@ColumnInfo(name = "current_generation_first_timestamp")
val currentGenerationFirstTimestamp: Long,
@ColumnInfo(name = "next_counter")
val nextCounter: Long,
@ColumnInfo(name = "current_generation_key")
val currentGenerationKey: ByteArray?,
val status: ProcessingStatus
) {
companion object {
private const val GENERATION_DURATION_LIMIT = 1000 * 60 * 60 * 24 * 7 // 7 days
private const val GENERATION_COUNTER_LIMIT = 16L
const val TYPE_APP_LIST_BASE = 1
const val TYPE_APP_LIST_DIFF = 2
fun buildFor(deviceId: String?, categoryId: String?, type: Int, params: CryptContainer.EncryptParameters) = CryptContainerMetadata(
cryptContainerId = 0,
deviceId = deviceId,
categoryId = categoryId,
type = type,
serverVersion = "",
currentGeneration = params.generation,
currentGenerationFirstTimestamp = System.currentTimeMillis(),
nextCounter = params.counter + 1,
currentGenerationKey = params.key,
status = ProcessingStatus.Finished
)
fun isTypeValid(type: Int) = type == TYPE_APP_LIST_BASE || type == TYPE_APP_LIST_DIFF
}
enum class ProcessingStatus {
MissingKey,
DowngradeDetected,
Unprocessed,
CryptoDamage,
ContentDamage,
Finished
}
fun needsNewGeneration(): Boolean {
val now = System.currentTimeMillis()
val timeWentBackwards = now < currentGenerationFirstTimestamp
val timeLimitReached = now >= currentGenerationFirstTimestamp + GENERATION_DURATION_LIMIT
val counterLimitReached = nextCounter >= GENERATION_COUNTER_LIMIT
return timeWentBackwards or timeLimitReached or counterLimitReached
}
fun prepareEncryption(forceNewGeneration: Boolean): PrepareEncryptionResult {
return if (needsNewGeneration() || forceNewGeneration || currentGenerationKey == null) {
val newKey = CryptContainer.generateKey()
val generation = currentGeneration + 1
PrepareEncryptionResult(
params = CryptContainer.EncryptParameters(
key = newKey,
generation = generation,
counter = 0
),
newMetadata = copy(
currentGeneration = generation,
nextCounter = 1,
currentGenerationFirstTimestamp = System.currentTimeMillis(),
currentGenerationKey = newKey
)
)
} else {
PrepareEncryptionResult(
params = CryptContainer.EncryptParameters(
key = currentGenerationKey,
generation = currentGeneration,
counter = nextCounter
),
newMetadata = copy(
nextCounter = nextCounter + 1
)
)
}
}
data class PrepareEncryptionResult (
val params: CryptContainer.EncryptParameters,
val newMetadata: CryptContainerMetadata
)
}
class CryptContainerMetadataProcessingStatusConverter {
@TypeConverter
fun toStatus(input: Int): CryptContainerMetadata.ProcessingStatus = when (input) {
0 -> CryptContainerMetadata.ProcessingStatus.MissingKey
1 -> CryptContainerMetadata.ProcessingStatus.DowngradeDetected
2 -> CryptContainerMetadata.ProcessingStatus.Unprocessed
3 -> CryptContainerMetadata.ProcessingStatus.CryptoDamage
4 -> CryptContainerMetadata.ProcessingStatus.ContentDamage
5 -> CryptContainerMetadata.ProcessingStatus.Finished
else -> CryptContainerMetadata.ProcessingStatus.CryptoDamage
}
@TypeConverter
fun toInt(input: CryptContainerMetadata.ProcessingStatus): Int = when (input) {
CryptContainerMetadata.ProcessingStatus.MissingKey -> 0
CryptContainerMetadata.ProcessingStatus.DowngradeDetected -> 1
CryptContainerMetadata.ProcessingStatus.Unprocessed -> 2
CryptContainerMetadata.ProcessingStatus.CryptoDamage -> 3
CryptContainerMetadata.ProcessingStatus.ContentDamage -> 4
CryptContainerMetadata.ProcessingStatus.Finished -> 5
}
}

View file

@ -0,0 +1,48 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.data.model
import androidx.room.*
@Entity(
tableName = "crypt_container_pending_key_request",
foreignKeys = [
ForeignKey(
entity = CryptContainerMetadata::class,
childColumns = ["crypt_container_id"],
parentColumns = ["crypt_container_id"],
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE
)
],
indices = [
Index(
unique = true,
value = ["request_sequence_id"]
)
]
)
data class CryptContainerPendingKeyRequest (
@PrimaryKey
@ColumnInfo(name = "crypt_container_id")
val cryptContainerId: Long,
@ColumnInfo(name = "request_time_crypt_container_generation")
val requestTimeCryptContainerGeneration: Long,
@ColumnInfo(name = "request_sequence_id")
val requestSequenceId: Long,
@ColumnInfo(name = "request_key")
val requestKey: ByteArray
)

View file

@ -0,0 +1,43 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.data.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@Entity(
tableName = "device_public_key",
foreignKeys = [
ForeignKey(
entity = Device::class,
parentColumns = ["id"],
childColumns = ["device_id"],
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE
)
]
)
data class DevicePublicKey (
@PrimaryKey
@ColumnInfo(name = "device_id")
val deviceId: String,
@ColumnInfo(name = "public_key")
val publicKey: ByteArray,
@ColumnInfo(name = "next_sequence_number")
val nextSequenceNumber: Long
)

View file

@ -22,6 +22,7 @@ import androidx.room.*
import io.timelimit.android.crypto.Curve25519
import io.timelimit.android.data.IdGenerator
import io.timelimit.android.data.JsonSerializable
import io.timelimit.android.extensions.parseBase64
@Entity(
tableName = "user_key",
@ -64,7 +65,7 @@ data class UserKey(
while (reader.hasNext()) {
when (reader.nextName()) {
USER_ID -> userId = reader.nextString()
PUBLIC_KEY -> publicKey = Base64.decode(reader.nextString(), 0)
PUBLIC_KEY -> publicKey = reader.nextString().parseBase64()
LAST_USE -> lastUse = reader.nextLong()
else -> reader.skipValue()
}

View file

@ -0,0 +1,21 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.extensions
import android.util.Base64
fun String.parseBase64() = Base64.decode(this, Base64.DEFAULT)
fun ByteArray.base64() = Base64.encodeToString(this, Base64.NO_PADDING or Base64.NO_WRAP)

View file

@ -91,6 +91,8 @@ abstract class PlatformIntegration(
abstract fun getExitLog(length: Int): List<ExitLogItem>
abstract fun showNewDeviceNotification(title: String)
var installedAppsChangeListener: Runnable? = null
var systemClockChangeListener: Runnable? = null
}

View file

@ -62,6 +62,7 @@ import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.*
import kotlin.system.exitProcess
@ -821,4 +822,25 @@ class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectio
.map { ExitLogItem.fromApplicationExitInfo(it) }
} else emptyList()
}
override fun showNewDeviceNotification(title: String) {
NotificationChannels.createNotificationChannels(notificationManager, context)
notificationManager.notify(
UUID.randomUUID().toString(),
NotificationIds.NEW_DEVICE,
NotificationCompat.Builder(context, NotificationChannels.NEW_DEVICE)
.setSmallIcon(R.drawable.ic_stat_timelapse)
.setContentTitle(context.getString(R.string.notification_new_device_title))
.setContentText(title)
.setContentIntent(BackgroundActionService.getOpenAppIntent(context))
.setWhen(System.currentTimeMillis())
.setShowWhen(true)
.setLocalOnly(true)
.setAutoCancel(false)
.setOngoing(false)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
)
}
}

View file

@ -33,6 +33,7 @@ object NotificationIds {
const val LOCAL_UPDATE_NOTIFICATION = 7
const val WORKER_REPORT_UNINSTALL = 8
const val WORKER_SYNC_BACKGROUND = 9
const val NEW_DEVICE = 10
}
object NotificationChannels {
@ -45,6 +46,7 @@ object NotificationChannels {
const val BACKGROUND_SYNC_NOTIFICATION = "background sync"
const val TEMP_ALLOWED_APP = "temporarily allowed App"
const val APP_RESET = "app reset"
const val NEW_DEVICE = "new device"
private fun createAppStatusChannel(notificationManager: NotificationManager, context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@ -193,6 +195,25 @@ object NotificationChannels {
}
}
private fun createNewDeviceChannel(notificationManager: NotificationManager, context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notificationManager.createNotificationChannel(
NotificationChannel(
NEW_DEVICE,
context.getString(R.string.notification_channel_new_device_title),
NotificationManager.IMPORTANCE_LOW
).apply {
description = context.getString(R.string.notification_channel_new_device_description)
enableLights(false)
setSound(null, null)
enableVibration(false)
setShowBadge(true)
lockscreenVisibility = NotificationCompat.VISIBILITY_SECRET
}
)
}
}
fun createNotificationChannels(notificationManager: NotificationManager, context: Context) {
createAppStatusChannel(notificationManager, context)
createBlockedNotificationChannel(notificationManager, context)
@ -203,6 +224,7 @@ object NotificationChannels {
createBackgroundSyncChannel(notificationManager, context)
createTempAllowedAppChannel(notificationManager, context)
createAppResetChannel(notificationManager, context)
createNewDeviceChannel(notificationManager, context)
}
}

View file

@ -191,4 +191,6 @@ class DummyIntegration(
): Boolean = false
override fun getExitLog(length: Int): List<ExitLogItem> = emptyList()
override fun showNewDeviceNotification(title: String) = Unit
}

View file

@ -167,6 +167,8 @@ fun <T1, T2> mergeLiveDataWaitForValues(d1: LiveData<T1>, d2: LiveData<T2>): Liv
return result
}
data class SevenTuple<A, B, C, D, E, F, G>(val first: A, val second: B, val third: C, val forth: D, val fifth: E, val sixth: F, val seventh: G)
fun <T1, T2, T3> mergeLiveDataWaitForValues(d1: LiveData<T1>, d2: LiveData<T2>, d3: LiveData<T3>): LiveData<Triple<T1, T2, T3>> {
val result = MediatorLiveData<Triple<T1, T2, T3>>()
var state = Triple<Option<T1>, Option<T2>, Option<T3>>(Option.None(), Option.None(), Option.None())
@ -283,3 +285,111 @@ fun <T1, T2, T3, T4, T5> mergeLiveDataWaitForValues(d1: LiveData<T1>, d2: LiveDa
return result
}
fun <T1, T2, T3, T4, T5, T6> mergeLiveDataWaitForValues(d1: LiveData<T1>, d2: LiveData<T2>, d3: LiveData<T3>, d4: LiveData<T4>, d5: LiveData<T5>, d6: LiveData<T6>): LiveData<SixTuple<T1, T2, T3, T4, T5, T6>> {
val result = MediatorLiveData<SixTuple<T1, T2, T3, T4, T5, T6>>()
var state = SixTuple<Option<T1>, Option<T2>, Option<T3>, Option<T4>, Option<T5>, Option<T6>>(Option.None(), Option.None(), Option.None(), Option.None(), Option.None(), Option.None())
fun update() {
val (a, b, c, d, e, f) = state
if (a is Option.Some && b is Option.Some && c is Option.Some && d is Option.Some && e is Option.Some && f is Option.Some) {
result.value = SixTuple(a.value, b.value, c.value, d.value, e.value, f.value)
}
}
result.addSource(d1) {
state = state.copy(first = Option.Some(it))
update()
}
result.addSource(d2) {
state = state.copy(second = Option.Some(it))
update()
}
result.addSource(d3) {
state = state.copy(third = Option.Some(it))
update()
}
result.addSource(d4) {
state = state.copy(forth = Option.Some(it))
update()
}
result.addSource(d5) {
state = state.copy(fifth = Option.Some(it))
update()
}
result.addSource(d6) {
state = state.copy(sixth = Option.Some(it))
update()
}
return result
}
fun <T1, T2, T3, T4, T5, T6, T7> mergeLiveDataWaitForValues(d1: LiveData<T1>, d2: LiveData<T2>, d3: LiveData<T3>, d4: LiveData<T4>, d5: LiveData<T5>, d6: LiveData<T6>, d7: LiveData<T7>): LiveData<SevenTuple<T1, T2, T3, T4, T5, T6, T7>> {
val result = MediatorLiveData<SevenTuple<T1, T2, T3, T4, T5, T6, T7>>()
var state = SevenTuple<Option<T1>, Option<T2>, Option<T3>, Option<T4>, Option<T5>, Option<T6>, Option<T7>>(Option.None(), Option.None(), Option.None(), Option.None(), Option.None(), Option.None(), Option.None())
fun update() {
val (a, b, c, d, e, f, g) = state
if (a is Option.Some && b is Option.Some && c is Option.Some && d is Option.Some && e is Option.Some && f is Option.Some && g is Option.Some) {
result.value = SevenTuple(a.value, b.value, c.value, d.value, e.value, f.value, g.value)
}
}
result.addSource(d1) {
state = state.copy(first = Option.Some(it))
update()
}
result.addSource(d2) {
state = state.copy(second = Option.Some(it))
update()
}
result.addSource(d3) {
state = state.copy(third = Option.Some(it))
update()
}
result.addSource(d4) {
state = state.copy(forth = Option.Some(it))
update()
}
result.addSource(d5) {
state = state.copy(fifth = Option.Some(it))
update()
}
result.addSource(d6) {
state = state.copy(sixth = Option.Some(it))
update()
}
result.addSource(d7) {
state = state.copy(seventh = Option.Some(it))
update()
}
return result
}

View file

@ -21,14 +21,13 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import io.timelimit.android.data.Database
import io.timelimit.android.data.model.Device
import io.timelimit.android.data.model.ExperimentalFlags
import io.timelimit.android.data.model.User
import io.timelimit.android.integration.platform.PlatformIntegration
import io.timelimit.android.integration.time.TimeApi
import io.timelimit.android.livedata.*
import io.timelimit.android.logic.applist.SyncInstalledAppsLogic
import io.timelimit.android.sync.SyncUtil
import io.timelimit.android.sync.network.api.ServerApi
import io.timelimit.android.sync.websocket.NetworkStatus
import io.timelimit.android.sync.websocket.NetworkStatusInterface
import io.timelimit.android.sync.websocket.WebsocketClientCreator

View file

@ -15,16 +15,26 @@
*/
package io.timelimit.android.logic
import io.timelimit.android.data.Database
import io.timelimit.android.livedata.liveDataFromNonNullValue
import io.timelimit.android.livedata.map
import io.timelimit.android.livedata.switchMap
class ServerApiLevelLogic(logic: AppLogic) {
val infoLive = logic.database.config().getDeviceAuthTokenAsync().switchMap { authToken ->
companion object {
fun getSync(database: Database): ServerApiLevelInfo = if (database.config().getDeviceAuthTokenSync().isEmpty())
ServerApiLevelInfo.Offline
else
ServerApiLevelInfo.Online(serverLevel = database.config().getServerApiLevelSync())
}
private val database = logic.database
val infoLive = database.config().getDeviceAuthTokenAsync().switchMap { authToken ->
if (authToken.isEmpty())
liveDataFromNonNullValue(ServerApiLevelInfo.Offline)
else
logic.database.config().getServerApiLevelLive().map { apiLevel ->
database.config().getServerApiLevelLive().map { apiLevel ->
ServerApiLevelInfo.Online(serverLevel = apiLevel)
}
}

View file

@ -1,261 +0,0 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.logic
import android.util.Log
import android.widget.Toast
import androidx.lifecycle.MutableLiveData
import io.timelimit.android.BuildConfig
import io.timelimit.android.R
import io.timelimit.android.async.Threads
import io.timelimit.android.coroutines.executeAndWait
import io.timelimit.android.coroutines.runAsyncExpectForever
import io.timelimit.android.data.model.App
import io.timelimit.android.data.model.AppActivity
import io.timelimit.android.data.model.ConsentFlags
import io.timelimit.android.data.model.UserType
import io.timelimit.android.integration.platform.ProtectionLevel
import io.timelimit.android.livedata.*
import io.timelimit.android.sync.actions.*
import io.timelimit.android.sync.actions.apply.ApplyActionUtil
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class SyncInstalledAppsLogic(val appLogic: AppLogic) {
companion object {
private const val LOG_TAG = "SyncInstalledAppsLogic"
}
private val doSyncLock = Mutex()
private var requestSync = MutableLiveData<Boolean>().apply { value = false }
private fun requestSync() {
requestSync.value = true
}
private val deviceStateLive = mergeLiveDataWaitForValues(
appLogic.deviceEntryIfEnabled,
appLogic.database.config().isConsentFlagSetAsync(ConsentFlags.APP_LIST_SYNC),
appLogic.database.config().getDeviceAuthTokenAsync().map { it.isEmpty() },
appLogic.deviceUserEntry,
appLogic.deviceEntryIfEnabled.switchMap { deviceEntry ->
val defaultUser = deviceEntry?.defaultUser
if (defaultUser.isNullOrEmpty()) liveDataFromNullableValue(null)
else appLogic.database.user().getUserByIdLive(defaultUser)
}
).map { (deviceEntry, hasSyncConsent, isLocalMode, deviceUser, deviceDefaultUser) ->
deviceEntry?.let { device ->
DeviceState(
id = device.id,
isCurrentUserChild = deviceUser?.type == UserType.Child,
isDefaultUserChild = deviceDefaultUser?.type == UserType.Child,
enableActivityLevelBlocking = device.enableActivityLevelBlocking,
isDeviceOwner = device.currentProtectionLevel == ProtectionLevel.DeviceOwner,
hasSyncConsent = hasSyncConsent,
isLocalMode = isLocalMode
)
}
}.ignoreUnchanged()
val shouldAskForConsent = deviceStateLive.map { it?.shouldAskForConsent ?: false }.ignoreUnchanged()
private fun getDeviceStateSync(): DeviceState? {
val userAndDeviceData = appLogic.database.derivedDataDao().getUserAndDeviceRelatedDataSync() ?: return null
val deviceRelatedData = userAndDeviceData.deviceRelatedData
val device = deviceRelatedData.deviceEntry
val defaultUser = if (device.defaultUser.isNotEmpty()) appLogic.database.user().getUserByIdSync(device.defaultUser) else null
return DeviceState(
id = device.id,
isCurrentUserChild = userAndDeviceData.userRelatedData?.user?.type == UserType.Child,
isDefaultUserChild = defaultUser?.type == UserType.Child,
enableActivityLevelBlocking = device.enableActivityLevelBlocking,
isDeviceOwner = device.currentProtectionLevel == ProtectionLevel.DeviceOwner,
hasSyncConsent = deviceRelatedData.consentFlags and ConsentFlags.APP_LIST_SYNC == ConsentFlags.APP_LIST_SYNC,
isLocalMode = deviceRelatedData.isLocalMode
)
}
init {
appLogic.platformIntegration.installedAppsChangeListener = Runnable { requestSync() }
deviceStateLive.observeForever { requestSync() }
runAsyncExpectForever { syncLoop() }
}
private suspend fun syncLoop() {
// wait a moment before the first sync
appLogic.timeApi.sleep(15 * 1000)
while (true) {
requestSync.waitUntilValueMatches { it == true }
requestSync.value = false
try {
doSyncNow()
// maximal 1 time per 5 seconds
appLogic.timeApi.sleep(5 * 1000)
} catch (ex: Exception) {
if (BuildConfig.DEBUG) {
Log.w(LOG_TAG, "could not sync installed app list", ex)
}
Toast.makeText(appLogic.context, R.string.background_logic_toast_sync_apps, Toast.LENGTH_SHORT).show()
appLogic.timeApi.sleep(45 * 1000)
requestSync.value = true
}
}
}
private suspend fun doSyncNow() {
doSyncLock.withLock {
val deviceState = Threads.database.executeAndWait { getDeviceStateSync() } ?: return
if (deviceState.isLocalMode) {
// local mode -> sync always
} else {
// connected mode -> don't sync always
if (!deviceState.hasSyncConsent) return@withLock
if (!deviceState.hasAnyChildUser) return@withLock
}
val deviceId = deviceState.id
val currentlyInstalledApps = getCurrentApps(deviceId)
run {
val currentlySaved = appLogic.database.app().getAppsByDeviceIdAsync(deviceId = deviceId).waitForNonNullValue().associateBy { app -> app.packageName }
// skip all items for removal which are still saved locally
val itemsToRemove = HashMap(currentlySaved)
currentlyInstalledApps.forEach { (packageName, _) -> itemsToRemove.remove(packageName) }
// only add items which are not the same locally
val itemsToAdd = currentlyInstalledApps.filter { (packageName, app) -> currentlySaved[packageName] != app }
// save the changes
if (itemsToRemove.isNotEmpty()) {
ApplyActionUtil.applyAppLogicAction(
action = RemoveInstalledAppsAction(packageNames = itemsToRemove.keys.toList()),
appLogic = appLogic,
ignoreIfDeviceIsNotConfigured = true
)
}
if (itemsToAdd.isNotEmpty()) {
ApplyActionUtil.applyAppLogicAction(
action = AddInstalledAppsAction(
apps = itemsToAdd.map {
(_, app) ->
InstalledApp(
packageName = app.packageName,
title = app.title,
recommendation = app.recommendation,
isLaunchable = app.isLaunchable
)
}
),
appLogic = appLogic,
ignoreIfDeviceIsNotConfigured = true
)
}
}
run {
fun buildKey(activity: AppActivity) = "${activity.appPackageName}:${activity.activityClassName}"
val currentlyInstalled = if (deviceState.enableActivityLevelBlocking)
Threads.backgroundOSInteraction.executeAndWait {
val realActivities = appLogic.platformIntegration.getLocalAppActivities(deviceId = deviceId)
val dummyActivities = currentlyInstalledApps.keys.map { packageName ->
AppActivity(
deviceId = deviceId,
appPackageName = packageName,
activityClassName = DummyApps.ACTIVITY_BACKGROUND_AUDIO,
title = appLogic.context.getString(R.string.dummy_app_activity_audio)
)
}
val allActivities = realActivities + dummyActivities
allActivities.associateBy { buildKey(it) }
}
else
emptyMap()
val currentlySaved = appLogic.database.appActivity().getAppActivitiesByDeviceIds(deviceIds = listOf(deviceId)).waitForNonNullValue().associateBy { buildKey(it) }
// skip all items for removal which are still saved locally
val itemsToRemove = HashMap(currentlySaved)
currentlyInstalled.forEach { (packageName, _) -> itemsToRemove.remove(packageName) }
// only add items which are not the same locally
val itemsToAdd = currentlyInstalled.filter { (packageName, app) -> currentlySaved[packageName] != app }
// save the changes
if (itemsToRemove.isNotEmpty() or itemsToAdd.isNotEmpty()) {
ApplyActionUtil.applyAppLogicAction(
action = UpdateAppActivitiesAction(
removedActivities = itemsToRemove.map { it.value.appPackageName to it.value.activityClassName },
updatedOrAddedActivities = itemsToAdd.map { item ->
AppActivityItem(
packageName = item.value.appPackageName,
className = item.value.activityClassName,
title = item.value.title
)
}
),
appLogic = appLogic,
ignoreIfDeviceIsNotConfigured = true
)
}
}
}
}
private suspend fun getCurrentApps(deviceId: String): Map<String, App> {
val currentlyInstalled = Threads.backgroundOSInteraction.executeAndWait {
appLogic.platformIntegration.getLocalApps(deviceId = deviceId).associateBy { app -> app.packageName }
}
val featureDummyApps = appLogic.platformIntegration.getFeatures().map {
DummyApps.forFeature(
id = it.id,
title = it.title,
deviceId = deviceId
)
}.associateBy { it.packageName }
return currentlyInstalled + featureDummyApps
}
internal data class DeviceState(
val id: String,
val isCurrentUserChild: Boolean,
val isDefaultUserChild: Boolean,
val enableActivityLevelBlocking: Boolean,
val isDeviceOwner: Boolean,
val hasSyncConsent: Boolean,
val isLocalMode: Boolean
) {
val hasAnyChildUser = isCurrentUserChild || isDefaultUserChild
val shouldAskForConsent = hasAnyChildUser && !isLocalMode && !hasSyncConsent
}
}

View file

@ -0,0 +1,74 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.logic.applist
import io.timelimit.android.proto.toAppActivityItem
import io.timelimit.android.proto.toInstalledApp
import io.timelimit.android.sync.actions.AddInstalledAppsAction
import io.timelimit.android.sync.actions.AppLogicAction
import io.timelimit.android.sync.actions.RemoveInstalledAppsAction
import io.timelimit.android.sync.actions.UpdateAppActivitiesAction
import io.timelimit.proto.applist.InstalledAppsDifferenceProto
import io.timelimit.proto.applist.InstalledAppsProto
import io.timelimit.proto.applist.RemovedAppActivityProto
object AppsDifferenceUtil {
fun calculateAppsDifference(old: InstalledAppsProto, current: InstalledAppsProto): InstalledAppsDifferenceProto {
val oldAppsByPackageName = old.apps.associateBy { it.package_name }
val packageNamesToRemove = (oldAppsByPackageName.keys - current.apps.map { it.package_name }.toSet()).toList()
val appsToAdd = current.apps.filter { app -> oldAppsByPackageName[app.package_name] != app }
val oldActivitiesIndexed = old.activities.associateBy { Pair(it.package_name, it.class_name) }
val currentActivitiesIndexed = current.activities.associateBy { Pair(it.package_name, it.class_name) }
val activitiesToRemove = (oldActivitiesIndexed.keys - currentActivitiesIndexed.keys)
.map { activity -> RemovedAppActivityProto(package_name = activity.first, class_name = activity.second) }
val activitiesToAdd = currentActivitiesIndexed.filter { (key, activity) -> oldActivitiesIndexed[key] != activity }
.values.toList()
return InstalledAppsDifferenceProto(
added = InstalledAppsProto(
apps = appsToAdd,
activities = activitiesToAdd
),
removed_packages = packageNamesToRemove,
removed_activities = activitiesToRemove
)
}
fun calculateAppsDifferenceActions(difference: InstalledAppsDifferenceProto, deviceId: String): List<AppLogicAction> {
val result = mutableListOf<AppLogicAction>()
if (difference.removed_packages.isNotEmpty()) {
result.add(RemoveInstalledAppsAction(packageNames = difference.removed_packages))
}
if (difference.added != null && difference.added.apps.isNotEmpty()) {
result.add(AddInstalledAppsAction(apps = difference.added.apps.map { it.toInstalledApp() }))
}
val addedActivities = difference.added?.activities ?: emptyList()
val removedActivities = difference.removed_activities
if (addedActivities.isNotEmpty() || removedActivities.isNotEmpty()) {
result.add(UpdateAppActivitiesAction(
removedActivities = removedActivities.map { it.package_name to it.class_name },
updatedOrAddedActivities = addedActivities.map { it.toAppActivityItem() }
))
}
return result
}
}

View file

@ -0,0 +1,193 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.logic.applist
import io.timelimit.android.async.Threads
import io.timelimit.android.coroutines.executeAndWait
import io.timelimit.android.crypto.CryptContainer
import io.timelimit.android.data.Database
import io.timelimit.android.data.model.CryptContainerData
import io.timelimit.android.data.model.CryptContainerMetadata
import io.timelimit.android.proto.build
import io.timelimit.android.proto.encodeDeflated
import io.timelimit.android.sync.SyncUtil
import io.timelimit.android.sync.actions.AppLogicAction
import io.timelimit.android.sync.actions.UpdateInstalledAppsAction
import io.timelimit.android.sync.actions.apply.ApplyActionUtil
import io.timelimit.proto.applist.InstalledAppsDifferenceProto
import io.timelimit.proto.applist.InstalledAppsProto
import io.timelimit.proto.applist.SavedAppsDifferenceProto
object CryptoAppListSync {
private const val SIZE_LIMIT_COMPRESSED = 1024 * 256
class TooLargeException: RuntimeException()
suspend fun sync(
deviceState: DeviceState,
database: Database,
installed: InstalledAppsProto,
syncUtil: SyncUtil,
disableLegacySync: Boolean
) {
fun dispatch(action: AppLogicAction) {
if (deviceState.isConnectedMode) {
ApplyActionUtil.addAppLogicActionToDatabaseSync(action, database)
}
}
val savedCrypt = Threads.database.executeAndWait {
InstalledAppsUtil.getEncryptedInstalledAppsFromDatabaseSync(database, deviceState.id)
}
if (savedCrypt == null) {
val baseKey = CryptContainer.EncryptParameters.generate()
val diffKey = CryptContainer.EncryptParameters.generate()
val baseEncrypted = CryptContainer.encrypt(installed.encodeDeflated(), baseKey)
val diffEncrypted = CryptContainer.encrypt(SavedAppsDifferenceProto.build(baseEncrypted, InstalledAppsDifferenceProto()).encodeDeflated(), diffKey)
if (baseEncrypted.size > SIZE_LIMIT_COMPRESSED) throw TooLargeException()
Threads.database.executeAndWait {
database.cryptContainer().removeDeviceCryptoMetadata(
deviceId = deviceState.id,
types = listOf(
CryptContainerMetadata.TYPE_APP_LIST_BASE,
CryptContainerMetadata.TYPE_APP_LIST_DIFF
)
)
val baseId = database.cryptContainer().insertMetadata(
CryptContainerMetadata.buildFor(
deviceId = deviceState.id,
categoryId = null,
type = CryptContainerMetadata.TYPE_APP_LIST_BASE,
params = baseKey
)
)
val diffId = database.cryptContainer().insertMetadata(
CryptContainerMetadata.buildFor(
deviceId = deviceState.id,
categoryId = null,
type = CryptContainerMetadata.TYPE_APP_LIST_DIFF,
params = diffKey
)
)
database.cryptContainer().insertData(
CryptContainerData(
cryptContainerId = baseId,
encryptedData = baseEncrypted
)
)
database.cryptContainer().insertData(
CryptContainerData(
cryptContainerId = diffId,
encryptedData = diffEncrypted
)
)
dispatch(UpdateInstalledAppsAction(
base = baseEncrypted,
diff = diffEncrypted,
wipe = disableLegacySync
))
}
syncUtil.requestImportantSync()
} else {
val diffCrypto = AppsDifferenceUtil.calculateAppsDifference(savedCrypt.base, installed)
if (diffCrypto != savedCrypt.diff) {
val baseSize = savedCrypt.base.adapter.encodedSize(savedCrypt.base)
val diffSize = diffCrypto.adapter.encodedSize(diffCrypto)
val needsNewBySize = diffSize >= baseSize / 10
val baseNeedsNewGeneration = savedCrypt.baseMeta.needsNewGeneration()
val diffNeedsNewGeneration = savedCrypt.diffMeta.needsNewGeneration() or baseNeedsNewGeneration
val diffCryptParams = savedCrypt.diffMeta.prepareEncryption(diffNeedsNewGeneration)
if (needsNewBySize or baseNeedsNewGeneration) {
val baseCryptParams = savedCrypt.baseMeta.prepareEncryption(baseNeedsNewGeneration)
val baseEncrypted = CryptContainer.encrypt(installed.encodeDeflated(), baseCryptParams.params)
val diffEncrypted = CryptContainer.encrypt(
SavedAppsDifferenceProto.build(baseEncrypted, InstalledAppsDifferenceProto()).encodeDeflated(),
diffCryptParams.params
)
if (baseEncrypted.size > SIZE_LIMIT_COMPRESSED) throw TooLargeException()
Threads.database.executeAndWait {
database.cryptContainer().updateMetadata(listOf(
baseCryptParams.newMetadata,
diffCryptParams.newMetadata
))
database.cryptContainer().updateData(listOf(
CryptContainerData(
cryptContainerId = savedCrypt.baseMeta.cryptContainerId,
encryptedData = baseEncrypted
),
CryptContainerData(
cryptContainerId = savedCrypt.diffMeta.cryptContainerId,
encryptedData = diffEncrypted
)
))
dispatch(UpdateInstalledAppsAction(
base = baseEncrypted,
diff = diffEncrypted,
wipe = disableLegacySync
))
}
syncUtil.requestImportantSync()
} else {
val diffEncrypted = CryptContainer.encrypt(
SavedAppsDifferenceProto.build(savedCrypt.baseHeader, diffCrypto).encodeDeflated(),
diffCryptParams.params
)
if (diffEncrypted.size > SIZE_LIMIT_COMPRESSED) throw TooLargeException()
Threads.database.executeAndWait {
database.cryptContainer().updateMetadata(diffCryptParams.newMetadata)
database.cryptContainer().updateData(
CryptContainerData(
cryptContainerId = savedCrypt.diffMeta.cryptContainerId,
encryptedData = diffEncrypted
)
)
dispatch(UpdateInstalledAppsAction(
base = null,
diff = diffEncrypted,
wipe = disableLegacySync
))
}
syncUtil.requestImportantSync()
}
}
}
}
}

View file

@ -0,0 +1,95 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.logic.applist
import androidx.lifecycle.LiveData
import io.timelimit.android.data.Database
import io.timelimit.android.data.model.ConsentFlags
import io.timelimit.android.data.model.ExperimentalFlags
import io.timelimit.android.data.model.UserType
import io.timelimit.android.integration.platform.ProtectionLevel
import io.timelimit.android.livedata.*
import io.timelimit.android.logic.AppLogic
import io.timelimit.android.logic.ServerApiLevelInfo
import io.timelimit.android.logic.ServerApiLevelLogic
data class DeviceState(
val id: String,
val isCurrentUserChild: Boolean,
val isDefaultUserChild: Boolean,
val enableActivityLevelBlocking: Boolean,
val isDeviceOwner: Boolean,
val hasSyncConsent: Boolean,
val isLocalMode: Boolean,
val disableLegacySync: Boolean,
val serverApiLevel: ServerApiLevelInfo
) {
companion object {
fun getSync(database: Database): DeviceState? {
val userAndDeviceData = database.derivedDataDao().getUserAndDeviceRelatedDataSync() ?: return null
val deviceRelatedData = userAndDeviceData.deviceRelatedData
val device = deviceRelatedData.deviceEntry
val defaultUser = if (device.defaultUser.isNotEmpty()) database.user().getUserByIdSync(device.defaultUser) else null
val disableLegacySync = deviceRelatedData.isExperimentalFlagSetSync(ExperimentalFlags.DISABLE_LEGACY_APP_SENDING)
val serverApiLevel = ServerApiLevelLogic.getSync(database)
return DeviceState(
id = device.id,
isCurrentUserChild = userAndDeviceData.userRelatedData?.user?.type == UserType.Child,
isDefaultUserChild = defaultUser?.type == UserType.Child,
enableActivityLevelBlocking = device.enableActivityLevelBlocking,
isDeviceOwner = device.currentProtectionLevel == ProtectionLevel.DeviceOwner,
hasSyncConsent = deviceRelatedData.consentFlags and ConsentFlags.APP_LIST_SYNC == ConsentFlags.APP_LIST_SYNC,
isLocalMode = deviceRelatedData.isLocalMode,
disableLegacySync = disableLegacySync,
serverApiLevel = serverApiLevel
)
}
fun getLive(appLogic: AppLogic): LiveData<DeviceState?> = mergeLiveDataWaitForValues(
appLogic.deviceEntryIfEnabled,
appLogic.database.config().isConsentFlagSetAsync(ConsentFlags.APP_LIST_SYNC),
appLogic.database.config().getDeviceAuthTokenAsync().map { it.isEmpty() },
appLogic.deviceUserEntry,
appLogic.deviceEntryIfEnabled.switchMap { deviceEntry ->
val defaultUser = deviceEntry?.defaultUser
if (defaultUser.isNullOrEmpty()) liveDataFromNullableValue(null)
else appLogic.database.user().getUserByIdLive(defaultUser)
},
appLogic.database.config().isExperimentalFlagsSetAsync(ExperimentalFlags.DISABLE_LEGACY_APP_SENDING),
appLogic.serverApiLevelLogic.infoLive
).map { (deviceEntry, hasSyncConsent, isLocalMode, deviceUser, deviceDefaultUser, disableLegacySync, serverApiLevel) ->
deviceEntry?.let { device ->
DeviceState(
id = device.id,
isCurrentUserChild = deviceUser?.type == UserType.Child,
isDefaultUserChild = deviceDefaultUser?.type == UserType.Child,
enableActivityLevelBlocking = device.enableActivityLevelBlocking,
isDeviceOwner = device.currentProtectionLevel == ProtectionLevel.DeviceOwner,
hasSyncConsent = hasSyncConsent,
isLocalMode = isLocalMode,
disableLegacySync = disableLegacySync,
serverApiLevel = serverApiLevel
)
}
}.ignoreUnchanged()
}
val hasAnyChildUser = isCurrentUserChild || isDefaultUserChild
val shouldAskForConsent = hasAnyChildUser && !isLocalMode && !hasSyncConsent
val isConnectedMode = !isLocalMode
}

View file

@ -0,0 +1,171 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.logic.applist
import android.util.Log
import io.timelimit.android.BuildConfig
import io.timelimit.android.R
import io.timelimit.android.async.Threads
import io.timelimit.android.coroutines.executeAndWait
import io.timelimit.android.crypto.CryptContainer
import io.timelimit.android.crypto.CryptException
import io.timelimit.android.data.Database
import io.timelimit.android.data.model.AppActivity
import io.timelimit.android.data.model.CryptContainerMetadata
import io.timelimit.android.livedata.waitForNonNullValue
import io.timelimit.android.logic.AppLogic
import io.timelimit.android.logic.DummyApps
import io.timelimit.android.proto.decodeInflated
import io.timelimit.android.proto.toProto
import io.timelimit.proto.applist.InstalledAppsDifferenceProto
import io.timelimit.proto.applist.InstalledAppsProto
import io.timelimit.proto.applist.SavedAppsDifferenceProto
import java.io.IOException
object InstalledAppsUtil {
private const val LOG_TAG = "InstalledAppsUtil"
suspend fun getInstalledAppsFromPlainDatabaseAsync(database: Database, deviceId: String): InstalledAppsProto {
return InstalledAppsProto(
apps = database.app().getAppsByDeviceIdAsync(deviceId = deviceId).waitForNonNullValue().map { it.toProto() },
activities = database.appActivity().getAppActivitiesByDeviceIds(deviceIds = listOf(deviceId)).waitForNonNullValue().map { it.toProto() }
)
}
fun getEncryptedInstalledAppsFromDatabaseSync(database: Database, deviceId: String): DecryptedInstalledApps? {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "getEncryptedInstalledAppsFromDatabaseSync()")
}
val baseValue = database.cryptContainer().getCryptoFullDataSyncByDeviceId(
deviceId = deviceId,
type = CryptContainerMetadata.TYPE_APP_LIST_BASE
)
val diffValue = database.cryptContainer().getCryptoFullDataSyncByDeviceId(
deviceId = deviceId,
type = CryptContainerMetadata.TYPE_APP_LIST_DIFF
)
if (
baseValue == null ||
baseValue.metadata.currentGenerationKey == null ||
baseValue.metadata.status != CryptContainerMetadata.ProcessingStatus.Finished ||
diffValue == null ||
diffValue.metadata.currentGenerationKey == null ||
diffValue.metadata.status != CryptContainerMetadata.ProcessingStatus.Finished
) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "incomplete data")
}
return null
}
val (baseHeader, baseDecrypted, diffDecrypted) = try {
val baseHeader = CryptContainer.Header.read(baseValue.encryptedData)
val baseDecrypted = CryptContainer.decrypt(
baseValue.metadata.currentGenerationKey,
baseValue.encryptedData
)
val diffDecrypted = CryptContainer.decrypt(
diffValue.metadata.currentGenerationKey,
diffValue.encryptedData
)
Triple(baseHeader, baseDecrypted, diffDecrypted)
} catch (ex: CryptException) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "could not decrypt previous data", ex)
}
return null
}
val (base, diff) = try {
val base = InstalledAppsProto.ADAPTER.decodeInflated(baseDecrypted)
val diff = SavedAppsDifferenceProto.ADAPTER.decodeInflated(diffDecrypted).apps
?: InstalledAppsDifferenceProto()
Pair(base, diff)
} catch (ex: IOException) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "could not decode data", ex)
}
return null
}
return DecryptedInstalledApps(
base = base,
baseMeta = baseValue.metadata,
baseHeader = baseHeader,
diff = diff,
diffMeta = diffValue.metadata
)
}
data class DecryptedInstalledApps(
val base: InstalledAppsProto,
val baseHeader: CryptContainer.Header,
val baseMeta: CryptContainerMetadata,
val diff: InstalledAppsDifferenceProto,
val diffMeta: CryptContainerMetadata
)
suspend fun getInstalledAppsFromOs(appLogic: AppLogic, deviceState: DeviceState): InstalledAppsProto {
val apps = kotlin.run {
val currentlyInstalled = Threads.backgroundOSInteraction.executeAndWait {
appLogic.platformIntegration.getLocalApps(deviceId = deviceState.id)
}
val featureDummyApps = appLogic.platformIntegration.getFeatures().map {
DummyApps.forFeature(
id = it.id,
title = it.title,
deviceId = deviceState.id
)
}
(currentlyInstalled + featureDummyApps).map { it.toProto() }
}
val activities = if (deviceState.enableActivityLevelBlocking)
Threads.backgroundOSInteraction.executeAndWait {
val realActivities = appLogic.platformIntegration.getLocalAppActivities(deviceId = deviceState.id)
val dummyActivities = apps.map { app ->
AppActivity(
deviceId = deviceState.id,
appPackageName = app.package_name,
activityClassName = DummyApps.ACTIVITY_BACKGROUND_AUDIO,
title = appLogic.context.getString(R.string.dummy_app_activity_audio)
)
}
(realActivities + dummyActivities).map { it.toProto() }
}
else
emptyList()
return InstalledAppsProto(
apps = apps,
activities = activities
)
}
}

View file

@ -0,0 +1,132 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.logic.applist
import android.util.Log
import android.widget.Toast
import androidx.lifecycle.MutableLiveData
import io.timelimit.android.BuildConfig
import io.timelimit.android.R
import io.timelimit.android.async.Threads
import io.timelimit.android.coroutines.executeAndWait
import io.timelimit.android.coroutines.runAsyncExpectForever
import io.timelimit.android.livedata.*
import io.timelimit.android.logic.AppLogic
import io.timelimit.android.sync.actions.apply.ApplyActionUtil
import io.timelimit.android.sync.actions.dispatch.LocalDatabaseAppLogicActionDispatcher
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class SyncInstalledAppsLogic(val appLogic: AppLogic) {
companion object {
private const val LOG_TAG = "SyncInstalledAppsLogic"
}
private val doSyncLock = Mutex()
private var requestSync = MutableLiveData<Boolean>().apply { value = false }
private fun requestSync() {
requestSync.value = true
}
private val deviceStateLive = DeviceState.getLive(appLogic)
val shouldAskForConsent = deviceStateLive.map { it?.shouldAskForConsent ?: false }.ignoreUnchanged()
init {
appLogic.platformIntegration.installedAppsChangeListener = Runnable { requestSync() }
deviceStateLive.observeForever { requestSync() }
runAsyncExpectForever { syncLoop() }
}
private suspend fun syncLoop() {
// wait a moment before the first sync
appLogic.timeApi.sleep(15 * 1000)
while (true) {
requestSync.waitUntilValueMatches { it == true }
requestSync.value = false
try {
doSyncNow()
// maximal 1 time per 5 seconds
appLogic.timeApi.sleep(5 * 1000)
} catch (ex: Exception) {
if (BuildConfig.DEBUG) {
Log.w(LOG_TAG, "could not sync installed app list", ex)
}
Toast.makeText(appLogic.context, R.string.background_logic_toast_sync_apps, Toast.LENGTH_SHORT).show()
appLogic.timeApi.sleep(45 * 1000)
requestSync.value = true
}
}
}
private suspend fun doSyncNow() {
doSyncLock.withLock {
val deviceState = Threads.database.executeAndWait { DeviceState.getSync(appLogic.database) } ?: return
if (deviceState.isLocalMode) {
// local mode -> sync always
} else {
// connected mode -> don't sync always
if (!deviceState.hasSyncConsent) return@withLock
if (!deviceState.hasAnyChildUser) return@withLock
}
val installed = InstalledAppsUtil.getInstalledAppsFromOs(appLogic, deviceState)
val savedPlain = InstalledAppsUtil.getInstalledAppsFromPlainDatabaseAsync(appLogic.database, deviceState.id)
val diffPlain = AppsDifferenceUtil.calculateAppsDifference(savedPlain, installed)
val diffPlainActions = AppsDifferenceUtil.calculateAppsDifferenceActions(diffPlain, deviceState.id)
if (deviceState.disableLegacySync) {
if (diffPlainActions.isNotEmpty()) {
Threads.database.executeAndWait {
diffPlainActions.forEach {
LocalDatabaseAppLogicActionDispatcher.dispatchAppLogicActionSync(
it, appLogic.database.config().getOwnDeviceIdSync()!!, appLogic.database
)
}
}
}
} else {
diffPlainActions.forEach { action ->
ApplyActionUtil.applyAppLogicAction(
action = action,
appLogic = appLogic,
ignoreIfDeviceIsNotConfigured = true
)
}
}
if (deviceState.isConnectedMode && deviceState.serverApiLevel.hasLevelOrIsOffline(4)) {
CryptoAppListSync.sync(
deviceState = deviceState,
database = appLogic.database,
installed = installed,
syncUtil = appLogic.syncUtil,
disableLegacySync = deviceState.disableLegacySync
)
}
}
}
}

View file

@ -0,0 +1,157 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.logic.crypto
import io.timelimit.android.crypto.CryptContainer
import io.timelimit.android.crypto.CryptException
import io.timelimit.android.crypto.Curve25519
import io.timelimit.android.data.Database
import io.timelimit.android.data.model.*
import io.timelimit.android.sync.actions.SendKeyRequestAction
import io.timelimit.android.sync.actions.apply.ApplyActionUtil
import io.timelimit.android.sync.network.ServerCryptContainer
object CryptDataHandler {
fun process(database: Database, data: ServerCryptContainer, type: Int, deviceId: String?, categoryId: String?): Result {
val oldItem = if (deviceId != null)
database.cryptContainer().getCryptoMetadataSyncByDeviceId(deviceId, type)
else if (categoryId != null)
database.cryptContainer().getCryptoMetadataSyncByCategoryId(categoryId, type)
else
database.cryptContainer().getCryptoMetadataSyncByType(type)
val isUnmodified = if (oldItem != null) {
val oldSavedData = database.cryptContainer().getData(oldItem.cryptContainerId)
oldSavedData != null && oldSavedData.encryptedData.contentEquals(data.data)
} else false
val currentItem = if (oldItem == null) {
val baseItem = CryptContainerMetadata(
cryptContainerId = 0,
deviceId = deviceId,
categoryId = categoryId,
type = type,
serverVersion = data.version,
currentGeneration = 0,
currentGenerationFirstTimestamp = System.currentTimeMillis(),
currentGenerationKey = null,
nextCounter = 0,
status = CryptContainerMetadata.ProcessingStatus.MissingKey
)
val cryptContainerId = database.cryptContainer().insertMetadata(baseItem)
baseItem.copy(cryptContainerId = cryptContainerId)
} else oldItem.copy(serverVersion = data.version)
if (deviceId == database.config().getOwnDeviceIdSync()) {
database.cryptContainer().updateMetadata(currentItem)
return Result(didCreateKeyRequests = false)
}
if (!isUnmodified) {
CryptContainerData(
cryptContainerId = currentItem.cryptContainerId,
encryptedData = data.data
).also { item ->
if (oldItem == null) database.cryptContainer().insertData(item)
else database.cryptContainer().updateData(item)
}
}
val header = try {
CryptContainer.Header.read(data.data)
} catch (ex: CryptException.InvalidContainer) {
null
}
val updatedMetadata = if (isUnmodified) {
currentItem
} else if (header == null) {
currentItem.copy(status = CryptContainerMetadata.ProcessingStatus.CryptoDamage)
} else if (header.generation < currentItem.currentGeneration) {
currentItem.copy(status = CryptContainerMetadata.ProcessingStatus.DowngradeDetected)
} else if (header.generation > currentItem.currentGeneration) {
currentItem.copy(status = CryptContainerMetadata.ProcessingStatus.MissingKey)
} else if (header.counter < currentItem.nextCounter) {
currentItem.copy(status = CryptContainerMetadata.ProcessingStatus.DowngradeDetected)
} else if (currentItem.currentGenerationKey == null) {
currentItem.copy(status = CryptContainerMetadata.ProcessingStatus.MissingKey)
} else {
try {
CryptContainer.decrypt(currentItem.currentGenerationKey, data.data)
currentItem.copy(
status = CryptContainerMetadata.ProcessingStatus.Unprocessed,
nextCounter = header.counter + 1
)
} catch (ex: CryptException.WrongKey) {
currentItem.copy(status = CryptContainerMetadata.ProcessingStatus.CryptoDamage)
}
}
database.cryptContainer().updateMetadata(updatedMetadata)
if (updatedMetadata.status == CryptContainerMetadata.ProcessingStatus.MissingKey && header != null) {
if (database.cryptContainerKeyRequest().byCryptContainerId(currentItem.cryptContainerId) == null) {
val signingKey = DeviceSigningKey.getPublicAndPrivateKeySync(database)!!.let { Curve25519.getPrivateKey(it) }
val requestKeyPair = Curve25519.generateKeyPair()
val sequenceNumber = database.config().getNextSigningSequenceNumberAndIncrementIt()
ApplyActionUtil.addAppLogicActionToDatabaseSync(
SendKeyRequestAction(
deviceSequenceNumber = sequenceNumber,
deviceId = deviceId,
categoryId = categoryId,
type = type,
tempKey = Curve25519.getPublicKey(requestKeyPair),
signature = Curve25519.sign(
signingKey,
KeyRequestSignedData(
deviceSequenceNumber = sequenceNumber,
deviceId = deviceId,
categoryId = categoryId,
type = type,
tempKey = Curve25519.getPublicKey(
requestKeyPair
)
).serialize()
)
), database
)
database.cryptContainerKeyRequest().insert(
CryptContainerPendingKeyRequest(
cryptContainerId = currentItem.cryptContainerId,
requestTimeCryptContainerGeneration = header.generation,
requestSequenceId = sequenceNumber,
requestKey = requestKeyPair
)
)
return Result(didCreateKeyRequests = true)
}
}
return Result(didCreateKeyRequests = false)
}
data class Result (val didCreateKeyRequests: Boolean) {
fun or(other: Result) = Result(this.didCreateKeyRequests or other.didCreateKeyRequests)
}
}

View file

@ -0,0 +1,56 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.logic.crypto
import io.timelimit.android.async.Threads
import io.timelimit.android.coroutines.executeAndWait
import io.timelimit.android.data.Database
import io.timelimit.android.logic.crypto.decrypt.DecryptProcessor
import io.timelimit.android.sync.network.ServerKeyRequest
import io.timelimit.android.sync.network.ServerKeyResponse
object CryptoSyncLogic {
suspend fun postPullHook(
database: Database,
keyRequests: List<ServerKeyRequest>,
keyResponses: List<ServerKeyResponse>
): Result {
return Threads.database.executeAndWait {
val privateKeyAndPublicKey = DeviceSigningKey.getPublicAndPrivateKeySync(database, uploadIfMissingAtServer = true)
?: return@executeAndWait Result(didSendReplies = false)
val keyRequestResult = KeyRequestProcessor.process(
keyRequests = keyRequests,
database = database,
privateKeyAndPublicKey = privateKeyAndPublicKey
)
val keyResponseResult = KeyResponseProcessor.process(
keyResponses = keyResponses,
database = database,
privateKeyAndPublicKey = privateKeyAndPublicKey
)
DecryptProcessor.handleEncryptedApps(database)
Result(
didSendReplies = keyRequestResult.didSendReplies or keyResponseResult.gotNewKeys
)
}
}
data class Result(val didSendReplies: Boolean)
}

View file

@ -0,0 +1,50 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.logic.crypto
import io.timelimit.android.crypto.Curve25519
import io.timelimit.android.data.Database
import io.timelimit.android.sync.actions.UploadDevicePublicKeyAction
import io.timelimit.android.sync.actions.apply.ApplyActionUtil
object DeviceSigningKey {
fun getPublicAndPrivateKeySync(database: Database, uploadIfMissingAtServer: Boolean = false): ByteArray? = database.runInTransaction {
if (database.config().getServerApiLevelSync() < 4) return@runInTransaction null
val oldData = database.config().getSigningKeySync()
if (oldData != null) {
if (database.deviceKey().getSync(database.config().getOwnDeviceIdSync()!!) == null && uploadIfMissingAtServer) {
ApplyActionUtil.addAppLogicActionToDatabaseSync(
UploadDevicePublicKeyAction(publicKey = Curve25519.getPublicKey(oldData)),
database
)
}
return@runInTransaction oldData
}
val newData = Curve25519.generateKeyPair()
database.config().setSigningKeySync(newData)
ApplyActionUtil.addAppLogicActionToDatabaseSync(
UploadDevicePublicKeyAction(publicKey = Curve25519.getPublicKey(newData)),
database
)
return@runInTransaction newData
}
}

View file

@ -0,0 +1,184 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.logic.crypto
import android.util.Log
import io.timelimit.android.BuildConfig
import io.timelimit.android.crypto.CryptContainer
import io.timelimit.android.crypto.Curve25519
import io.timelimit.android.data.Database
import io.timelimit.android.data.model.CryptContainerMetadata
import io.timelimit.android.sync.actions.ReplyToKeyRequestAction
import io.timelimit.android.sync.actions.apply.ApplyActionUtil
import io.timelimit.android.sync.network.ServerKeyRequest
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
object KeyRequestProcessor {
private const val LOG_TAG = "KeyRequestProcessor"
fun process(
keyRequests: List<ServerKeyRequest>,
database: Database,
privateKeyAndPublicKey: ByteArray
): Result {
var didSendReplies = false
if (keyRequests.isNotEmpty()) {
val lastServerSequence = database.config().getLastServerKeyRequestSequenceSync()
var newServerSequence: Long? = null
for (request in keyRequests) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "got key request: $request")
}
if (lastServerSequence != null && request.serverRequestSequenceNumber <= lastServerSequence) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because server sequence number repeated")
}
continue
}
if (newServerSequence == null || newServerSequence < request.serverRequestSequenceNumber) {
newServerSequence = request.serverRequestSequenceNumber
}
val deviceKey = database.deviceKey().getSync(request.senderDeviceId)
if (deviceKey == null) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because no device key is known")
}
continue
}
if (request.senderSequenceNumber < deviceKey.nextSequenceNumber) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because client sequence number repeated")
}
continue
}
if (deviceKey.publicKey.size != Curve25519.PUBLIC_KEY_SIZE || request.signature.size != Curve25519.SIGNATURE_SIZE) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because cryptographic data has the wrong size")
}
continue
}
val requestSignedData = KeyRequestSignedData(
deviceSequenceNumber = request.senderSequenceNumber,
deviceId = request.deviceId,
categoryId = request.categoryId,
type = request.type,
tempKey = request.tempKey
)
if (
!Curve25519.validateSignature(
deviceKey.publicKey,
requestSignedData.serialize(),
request.signature
)
) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because the signature is invalid")
}
continue
}
database.deviceKey().update(deviceKey.copy(nextSequenceNumber = request.senderSequenceNumber + 1))
val metadata = if (request.deviceId != null) {
database.cryptContainer().getCryptoMetadataSyncByDeviceId(request.deviceId, request.type)
} else if (request.categoryId != null) {
database.cryptContainer().getCryptoMetadataSyncByCategoryId(request.categoryId, request.type)
} else {
database.cryptContainer().getCryptoMetadataSyncByType(request.type)
}
if (
metadata?.currentGenerationKey == null ||
metadata.status == CryptContainerMetadata.ProcessingStatus.MissingKey
) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because the key is unknown")
}
continue
}
if (metadata.currentGenerationKey.size != CryptContainer.KEY_SIZE) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because the key has the wrong size")
}
continue
}
val selfTempKey = Curve25519.generateKeyPair()
val sharedSecret = Curve25519.sharedSecret(
publicKey = request.tempKey,
privateKey = Curve25519.getPrivateKey(selfTempKey)
)
val encryptedKey = Cipher.getInstance("AES/ECB/NoPadding").let {
// this encrypt just a single block and the integrity is checked using async crypto
// actually, xor could be enough for this case
it.init(Cipher.ENCRYPT_MODE, SecretKeySpec(sharedSecret.copyOfRange(0, CryptContainer.KEY_SIZE), "AES"))
it.doFinal(metadata.currentGenerationKey)
}
ApplyActionUtil.addAppLogicActionToDatabaseSync(
action = ReplyToKeyRequestAction(
requestServerSequenceNumber = request.serverRequestSequenceNumber,
tempKey = Curve25519.getPublicKey(selfTempKey),
encryptedKey = encryptedKey,
signature = Curve25519.sign(
privateKey = Curve25519.getPrivateKey(privateKeyAndPublicKey),
message = KeyResponseSignedData(
request = requestSignedData,
senderDevicePublicKey = deviceKey.publicKey,
tempKey = Curve25519.getPublicKey(selfTempKey),
encryptedKey = encryptedKey
).serialize()
)
),
database = database
)
didSendReplies = true
}
newServerSequence?.let {
database.config().setLastServerKeyRequestSequenceSync(it)
}
}
return Result(
didSendReplies = didSendReplies
)
}
data class Result(val didSendReplies: Boolean)
}

View file

@ -0,0 +1,83 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.logic.crypto
import okio.Buffer
data class KeyRequestSignedData (
val deviceSequenceNumber: Long,
val deviceId: String?,
val categoryId: String?,
val type: Int,
val tempKey: ByteArray
) {
init {
if (tempKey.size != 32) {
throw IllegalArgumentException()
}
}
fun serialize(): ByteArray = Buffer().also { buffer ->
fun writeBool(value: Boolean) = buffer.writeByte(when (value) {
false -> 0
true -> 1
})
fun writeString(value: String) = value.toByteArray(Charsets.UTF_8).also {
buffer.writeInt(it.size)
buffer.write(it)
}
fun writeOptionalString(value: String?) {
if (value == null) {
writeBool(false)
} else {
writeBool(true)
writeString(value)
}
}
writeString("KeyRequestSignedData")
buffer.writeLong(deviceSequenceNumber)
writeBool(deviceId != null)
writeOptionalString(deviceId)
writeOptionalString(categoryId)
buffer.writeInt(type)
buffer.write(tempKey) // fixed size => no length prefix required
}.readByteArray()
}
data class KeyResponseSignedData (
val request: KeyRequestSignedData,
val senderDevicePublicKey: ByteArray,
val tempKey: ByteArray,
val encryptedKey: ByteArray
) {
fun serialize(): ByteArray = Buffer().also { buffer ->
fun writeByteArray(array: ByteArray) {
buffer.writeInt(array.size)
buffer.write(array)
}
fun writeString(value: String) = writeByteArray(value.toByteArray(Charsets.UTF_8))
writeString("KeyResponseSignedData")
writeByteArray(senderDevicePublicKey)
writeByteArray(tempKey)
writeByteArray(encryptedKey)
buffer.write(request.serialize())
}.readByteArray()
}

View file

@ -0,0 +1,216 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.logic.crypto
import android.util.Log
import io.timelimit.android.BuildConfig
import io.timelimit.android.crypto.CryptContainer
import io.timelimit.android.crypto.CryptException
import io.timelimit.android.crypto.Curve25519
import io.timelimit.android.data.Database
import io.timelimit.android.data.model.CryptContainerKeyResult
import io.timelimit.android.data.model.CryptContainerMetadata
import io.timelimit.android.sync.actions.FinishKeyRequestAction
import io.timelimit.android.sync.actions.apply.ApplyActionUtil
import io.timelimit.android.sync.network.ServerKeyResponse
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
object KeyResponseProcessor {
private const val LOG_TAG = "KeyResponseProcessor"
fun process(
keyResponses: List<ServerKeyResponse>,
database: Database,
privateKeyAndPublicKey: ByteArray
): Result {
var gotNewKeys = false
if (keyResponses.isNotEmpty()) {
val lastServerSequence = database.config().getLastServerKeyResponseSequenceSync()
var newServerSequence: Long? = null
for (response in keyResponses) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "got key response: $response")
}
if (lastServerSequence != null && response.serverResponseSequenceNumber <= lastServerSequence) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because server sequence number repeated")
}
continue
}
if (newServerSequence == null || newServerSequence < response.serverResponseSequenceNumber) {
newServerSequence = response.serverResponseSequenceNumber
}
val deviceKey = database.deviceKey().getSync(response.senderDeviceId)
if (deviceKey == null) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because no device key is known")
}
continue
}
if (
deviceKey.publicKey.size != Curve25519.PUBLIC_KEY_SIZE ||
response.tempKey.size != Curve25519.PUBLIC_KEY_SIZE ||
response.signature.size != Curve25519.SIGNATURE_SIZE ||
response.encryptedKey.size != CryptContainer.KEY_SIZE
) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because cryptographic data has the wrong size")
}
continue
}
val requestMeta = database.cryptContainerKeyRequest().byRequestId(response.requestSequenceId)
if (requestMeta == null) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because no request was found")
}
continue
}
val cryptContainerMeta = database.cryptContainer().getCryptoMetadataSyncByContainerId(requestMeta.cryptContainerId)!!
if (
database.cryptContainerKeyResult().countResultItems(
requestSequenceNumber = requestMeta.requestSequenceId,
deviceId = response.senderDeviceId
) > 0
) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because this device sent already an invalid reply")
}
continue
}
val requestSignedData = KeyRequestSignedData(
deviceSequenceNumber = requestMeta.requestSequenceId,
deviceId = cryptContainerMeta.deviceId,
categoryId = cryptContainerMeta.categoryId,
type = cryptContainerMeta.type,
tempKey = Curve25519.getPublicKey(requestMeta.requestKey)
)
val responseSignedData = KeyResponseSignedData(
request = requestSignedData,
senderDevicePublicKey = Curve25519.getPublicKey(privateKeyAndPublicKey),
encryptedKey = response.encryptedKey,
tempKey = response.tempKey
)
if (
!Curve25519.validateSignature(
deviceKey.publicKey,
responseSignedData.serialize(),
response.signature
)
) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because the signature is invalid")
}
continue
}
val sharedSecret = Curve25519.sharedSecret(
publicKey = response.tempKey,
privateKey = Curve25519.getPrivateKey(requestMeta.requestKey)
)
val decryptedKey = Cipher.getInstance("AES/ECB/NoPadding").let {
// this encrypt just a single block and the integrity is checked using async crypto
// actually, xor could be enough for this case
it.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret.copyOfRange(0, CryptContainer.KEY_SIZE), "AES"))
it.doFinal(response.encryptedKey)
}
val encryptedData = database.cryptContainer().getData(cryptContainerMeta.cryptContainerId)!!
val encryptedDataHeader = try {
CryptContainer.Header.read(encryptedData.encryptedData)
} catch (ex: CryptException.InvalidContainer) {
null
}
val isKeyValid = try {
CryptContainer.decrypt(decryptedKey, encryptedData.encryptedData)
true
} catch (ex: CryptException.WrongKey) {
false
}
if (isKeyValid && encryptedDataHeader != null) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "got valid key")
}
gotNewKeys = true
ApplyActionUtil.addAppLogicActionToDatabaseSync(
action = FinishKeyRequestAction(deviceSequenceNumber = requestMeta.requestSequenceId),
database = database
)
database.cryptContainerKeyRequest().delete(requestMeta)
database.cryptContainer().updateMetadata(
cryptContainerMeta.copy(
currentGeneration = encryptedDataHeader.generation,
currentGenerationFirstTimestamp = System.currentTimeMillis(),
nextCounter = encryptedDataHeader.counter + 1,
currentGenerationKey = decryptedKey,
status = CryptContainerMetadata.ProcessingStatus.Unprocessed
)
)
} else {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "got invalid key")
}
database.cryptContainerKeyResult().insert(
CryptContainerKeyResult(
requestSequenceId = requestMeta.requestSequenceId,
deviceId = response.senderDeviceId,
status = CryptContainerKeyResult.Status.InvalidKey
)
)
}
}
newServerSequence?.let {
database.config().setLastServerKeyResponseSequenceSync(it)
}
}
return Result(gotNewKeys = gotNewKeys)
}
data class Result(
val gotNewKeys: Boolean
)
}

View file

@ -0,0 +1,184 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.logic.crypto.decrypt
import android.util.Log
import io.timelimit.android.BuildConfig
import io.timelimit.android.crypto.CryptContainer
import io.timelimit.android.crypto.CryptException
import io.timelimit.android.data.Database
import io.timelimit.android.data.dao.CryptContainerDao
import io.timelimit.android.data.model.App
import io.timelimit.android.data.model.AppActivity
import io.timelimit.android.data.model.CryptContainerMetadata
import io.timelimit.android.proto.decodeInflated
import io.timelimit.android.proto.toDb
import io.timelimit.proto.applist.InstalledAppsProto
import io.timelimit.proto.applist.SavedAppsDifferenceProto
import java.io.IOException
object DecryptProcessor {
private const val LOG_TAG = "DecryptProcessor"
fun handleEncryptedApps(database: Database) {
val unprocessed = database.cryptContainer().getMetadataByProcessingStatus(CryptContainerMetadata.ProcessingStatus.Unprocessed)
val finishedDeviceIds = mutableSetOf<String>()
for (metadata in unprocessed) {
if (
metadata.type == CryptContainerMetadata.TYPE_APP_LIST_BASE ||
metadata.type == CryptContainerMetadata.TYPE_APP_LIST_DIFF
) {
if (
metadata.deviceId == null ||
finishedDeviceIds.contains(metadata.deviceId) ||
metadata.deviceId == database.config().getOwnDeviceIdSync()
) continue
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "found data for ${metadata.deviceId}")
}
finishedDeviceIds.add(metadata.deviceId)
val baseData = database.cryptContainer().getCryptoFullDataSyncByDeviceId(metadata.deviceId, CryptContainerMetadata.TYPE_APP_LIST_BASE) ?: continue
val diffData = database.cryptContainer().getCryptoFullDataSyncByDeviceId(metadata.deviceId, CryptContainerMetadata.TYPE_APP_LIST_DIFF) ?: continue
if (!(isReadyForProcessing(baseData) && isReadyForProcessing(diffData))) continue
val (baseDecrypted, baseHeader) = try {
CryptContainer.decrypt(
baseData.metadata.currentGenerationKey ?: continue,
baseData.encryptedData
) to CryptContainer.Header.read(baseData.encryptedData)
} catch (ex: CryptException) {
database.cryptContainer().updateMetadata(baseData.metadata.copy(status = CryptContainerMetadata.ProcessingStatus.CryptoDamage))
continue
}
val diffDecrypted = try {
CryptContainer.decrypt(
diffData.metadata.currentGenerationKey ?: continue,
diffData.encryptedData
)
} catch (ex: CryptException) {
database.cryptContainer().updateMetadata(diffData.metadata.copy(status = CryptContainerMetadata.ProcessingStatus.CryptoDamage))
continue
}
val baseContent = try {
InstalledAppsProto.ADAPTER.decodeInflated(baseDecrypted)
} catch (ex: IOException) {
database.cryptContainer().updateMetadata(baseData.metadata.copy(status = CryptContainerMetadata.ProcessingStatus.ContentDamage))
continue
}
val diffContent = try {
SavedAppsDifferenceProto.ADAPTER.decodeInflated(diffDecrypted)
} catch (ex: IOException) {
database.cryptContainer().updateMetadata(diffData.metadata.copy(status = CryptContainerMetadata.ProcessingStatus.ContentDamage))
continue
}
if (
diffContent.base_generation != baseHeader.generation ||
diffContent.base_counter != baseHeader.counter
) {
database.cryptContainer().updateMetadata(diffData.metadata.copy(status = CryptContainerMetadata.ProcessingStatus.ContentDamage))
continue
}
database.app().deleteAllAppsByDeviceId(metadata.deviceId)
database.appActivity().deleteAppActivitiesByDeviceIds(listOf(metadata.deviceId))
database.app().addAppsSync(
baseContent.apps.map {
App(
deviceId = metadata.deviceId,
packageName = it.package_name,
title = it.title,
isLaunchable = it.is_launchable,
recommendation = it.recommendation.toDb()
)
}
)
database.appActivity().addAppActivitiesSync(
baseContent.activities.map {
AppActivity(
deviceId = metadata.deviceId,
appPackageName = it.package_name,
activityClassName = it.class_name,
title = it.title
)
}
)
database.app().removeAppsByDeviceIdAndPackageNamesSync(
metadata.deviceId,
diffContent.apps?.removed_packages ?: emptyList()
)
diffContent.apps?.removed_activities?.forEach {
database.appActivity().deleteAppActivitiesSync(
deviceId = metadata.deviceId,
packageName = it.package_name,
activities = listOf(it.class_name)
)
}
database.app().addAppsSync(
diffContent.apps?.added?.apps?.map {
App(
deviceId = metadata.deviceId,
packageName = it.package_name,
title = it.title,
isLaunchable = it.is_launchable,
recommendation = it.recommendation.toDb()
)
} ?: emptyList()
)
database.appActivity().addAppActivitiesSync(
diffContent.apps?.added?.activities?.map {
AppActivity(
deviceId = metadata.deviceId,
appPackageName = it.package_name,
activityClassName = it.class_name,
title = it.title
)
} ?: emptyList()
)
database.cryptContainer().updateMetadata(listOf(
baseData.metadata.copy(status = CryptContainerMetadata.ProcessingStatus.Finished),
diffData.metadata.copy(status = CryptContainerMetadata.ProcessingStatus.Finished)
))
}
}
}
private fun isReadyForProcessing(value: CryptContainerDao.MetadataAndContent) = when (value.metadata.status) {
CryptContainerMetadata.ProcessingStatus.Unprocessed -> true
CryptContainerMetadata.ProcessingStatus.Finished -> true
else -> false
}
}

View file

@ -0,0 +1,75 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.proto
import io.timelimit.android.crypto.CryptContainer
import io.timelimit.android.data.model.App
import io.timelimit.android.data.model.AppActivity
import io.timelimit.android.data.model.AppRecommendation
import io.timelimit.android.sync.actions.AppActivityItem
import io.timelimit.android.sync.actions.InstalledApp
import io.timelimit.proto.applist.InstalledAppActivityProto
import io.timelimit.proto.applist.InstalledAppProto
import io.timelimit.proto.applist.InstalledAppsDifferenceProto
import io.timelimit.proto.applist.SavedAppsDifferenceProto
fun InstalledAppProto.Recommendation.toDb(): AppRecommendation = when (this) {
InstalledAppProto.Recommendation.NONE -> AppRecommendation.None
InstalledAppProto.Recommendation.WHITELIST -> AppRecommendation.Whitelist
InstalledAppProto.Recommendation.BLACKLIST -> AppRecommendation.Blacklist
}
fun AppRecommendation.toProto(): InstalledAppProto.Recommendation = when (this) {
AppRecommendation.None -> InstalledAppProto.Recommendation.NONE
AppRecommendation.Whitelist -> InstalledAppProto.Recommendation.WHITELIST
AppRecommendation.Blacklist -> InstalledAppProto.Recommendation.BLACKLIST
}
fun InstalledAppProto.toInstalledApp(): InstalledApp = InstalledApp(
packageName = this.package_name,
title = this.title,
recommendation = this.recommendation.toDb(),
isLaunchable = this.is_launchable
)
fun App.toProto(): InstalledAppProto = InstalledAppProto(
package_name = this.packageName,
title = this.title,
is_launchable = this.isLaunchable,
recommendation = this.recommendation.toProto()
)
fun InstalledAppActivityProto.toAppActivityItem(): AppActivityItem = AppActivityItem(
packageName = this.package_name,
className = this.class_name,
title = this.title
)
fun AppActivity.toProto(): InstalledAppActivityProto = InstalledAppActivityProto(
package_name = this.appPackageName,
class_name = this.activityClassName,
title = this.title
)
fun SavedAppsDifferenceProto.Companion.build(header: CryptContainer.Header, diff: InstalledAppsDifferenceProto): SavedAppsDifferenceProto =
SavedAppsDifferenceProto(
base_generation = header.generation,
base_counter = header.counter,
apps = diff
)
fun SavedAppsDifferenceProto.Companion.build(encryptedBaseData: ByteArray, diff: InstalledAppsDifferenceProto): SavedAppsDifferenceProto =
build(CryptContainer.Header.read(encryptedBaseData), diff)

View file

@ -0,0 +1,32 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.proto
import com.squareup.wire.Message
import com.squareup.wire.ProtoAdapter
import okio.Buffer
import okio.use
import java.io.ByteArrayInputStream
import java.util.zip.DeflaterOutputStream
import java.util.zip.InflaterInputStream
fun <A : Message<A, B>, B : Message.Builder<A, B>> Message<A, B>.encodeDeflated(): ByteArray = Buffer().also { buffer ->
DeflaterOutputStream(buffer.outputStream()).use { this.encode(it) }
}.readByteArray()
fun <T> ProtoAdapter<T>.decodeInflated(input: ByteArray): T = InflaterInputStream(ByteArrayInputStream(input)).use {
this.decode(it)
}

View file

@ -21,21 +21,21 @@ import io.timelimit.android.coroutines.runAsync
import io.timelimit.android.data.Database
import io.timelimit.android.data.model.*
import io.timelimit.android.integration.platform.PlatformIntegration
import io.timelimit.android.sync.actions.DatabaseValidation
import io.timelimit.android.sync.actions.DeleteCategoryAction
import io.timelimit.android.sync.actions.RemoveUserAction
import io.timelimit.android.logic.crypto.CryptDataHandler
import io.timelimit.android.sync.actions.*
import io.timelimit.android.sync.actions.dispatch.LocalDatabaseParentActionDispatcher
import io.timelimit.android.sync.network.ServerCryptContainer
import io.timelimit.android.sync.network.ServerDataStatus
object ApplyServerDataStatus {
suspend fun applyServerDataStatusCoroutine(status: ServerDataStatus, database: Database, platformIntegration: PlatformIntegration) {
Threads.database.executeAndWait {
suspend fun applyServerDataStatusCoroutine(status: ServerDataStatus, database: Database, platformIntegration: PlatformIntegration): Result {
return Threads.database.executeAndWait {
applyServerDataStatusSync(status, database, platformIntegration)
}
}
fun applyServerDataStatusSync(status: ServerDataStatus, database: Database, platformIntegration: PlatformIntegration) {
database.runInTransaction {
fun applyServerDataStatusSync(status: ServerDataStatus, database: Database, platformIntegration: PlatformIntegration): Result {
return database.runInTransaction {
// this would override some local data which was not sent yet
// so it's better to cancel in this case (or, more complicated,
// apply the delta which was not sent locally)
@ -53,6 +53,8 @@ object ApplyServerDataStatus {
database.config().setServerApiLevelSync(status.apiLevel)
}
var didCreateNewActions = false
run {
val newUserList = status.newUserList
@ -123,9 +125,10 @@ object ApplyServerDataStatus {
}
}
run {
val newDeviceTitles = run {
// apply new device list
val newDeviceList = status.newDeviceList
val newDeviceTitles = mutableListOf<String>()
if (newDeviceList != null) {
val oldDeviceList = database.device().getAllDevicesSync()
@ -191,6 +194,8 @@ object ApplyServerDataStatus {
qOrLater = newDevice.qOrLater,
manipulationFlags = newDevice.manipulationFlags
))
newDeviceTitles.add(newDevice.name)
} else {
// eventually update old entry
@ -239,19 +244,58 @@ object ApplyServerDataStatus {
}
}
}
if (newDevice.publicKey != null) {
val entry = database.deviceKey().getSync(newDevice.deviceId)
if (entry == null) {
database.deviceKey().insert(DevicePublicKey(
deviceId = newDevice.deviceId,
publicKey = newDevice.publicKey,
nextSequenceNumber = 0
))
}
}
}
}
database.config().setDeviceListVersionSync(newDeviceList.version)
}
newDeviceTitles.toList()
}
run {
status.newInstalledApps.forEach {
item ->
status.updatedExtendedDeviceData.forEach { item ->
fun handle(data: ServerCryptContainer, type: Int) {
val result = CryptDataHandler.process(
database = database,
data = data,
type = type,
categoryId = null,
deviceId = item.deviceId
)
if (result.didCreateKeyRequests) {
didCreateNewActions = true
}
}
item.appsBase?.let { handle(it, CryptContainerMetadata.TYPE_APP_LIST_BASE) }
item.appsDiff?.let { handle(it, CryptContainerMetadata.TYPE_APP_LIST_DIFF) }
}
}
run {
val disableLegacySync = database.config().isExperimentalFlagsSetSync(ExperimentalFlags.DISABLE_LEGACY_APP_SENDING)
for (item in status.newInstalledApps) {
DatabaseValidation.assertDeviceExists(database, item.deviceId)
if (
database.cryptContainer().getCryptoMetadataSyncByDeviceId(item.deviceId, CryptContainerMetadata.TYPE_APP_LIST_BASE) == null ||
(item.deviceId == database.config().getOwnDeviceIdSync() && !disableLegacySync)
) {
run {
// apply apps
database.app().deleteAllAppsByDeviceId(item.deviceId)
@ -268,7 +312,8 @@ object ApplyServerDataStatus {
run {
// apply activities
database.appActivity().deleteAppActivitiesByDeviceIds(listOf(item.deviceId))
database.appActivity()
.deleteAppActivitiesByDeviceIds(listOf(item.deviceId))
database.appActivity().addAppActivitiesSync(item.activities.map {
AppActivity(
deviceId = item.deviceId,
@ -278,6 +323,7 @@ object ApplyServerDataStatus {
)
})
}
}
run {
// apply changed version number
@ -565,8 +611,24 @@ object ApplyServerDataStatus {
}
}
}
Result(
newDeviceTitles = newDeviceTitles,
didCreateNewActions = didCreateNewActions
)
}
}
fun postNotifications(result: Result, platformIntegration: PlatformIntegration) {
result.newDeviceTitles.forEach { deviceTitle ->
platformIntegration.showNewDeviceNotification(deviceTitle)
}
}
class PendingSyncActionException: RuntimeException()
data class Result (
val newDeviceTitles: List<String>,
val didCreateNewActions: Boolean
)
}

View file

@ -27,6 +27,7 @@ import io.timelimit.android.coroutines.runAsync
import io.timelimit.android.livedata.*
import io.timelimit.android.logic.AppLogic
import io.timelimit.android.logic.ServerLogic
import io.timelimit.android.logic.crypto.CryptoSyncLogic
import io.timelimit.android.sync.actions.apply.UploadActionsUtil
import io.timelimit.android.sync.network.ClientDataStatus
import io.timelimit.android.sync.network.api.UnauthorizedHttpError
@ -246,9 +247,19 @@ class SyncUtil (private val logic: AppLogic) {
}
private suspend fun pullStatus(server: ServerLogic.ServerConfig) {
val currentStatus = ClientDataStatus.getClientDataStatusAsync(logic.database)
val currentStatus = Threads.database.executeAndWait { ClientDataStatus.getClientDataStatusSync(logic.database) }
val serverResponse = server.api.pullChanges(server.deviceAuthToken, currentStatus)
ApplyServerDataStatus.applyServerDataStatusCoroutine(serverResponse, logic.database, logic.platformIntegration)
val applyResult = ApplyServerDataStatus.applyServerDataStatusCoroutine(serverResponse, logic.database, logic.platformIntegration)
ApplyServerDataStatus.postNotifications(applyResult, logic.platformIntegration)
val cryptoResult = CryptoSyncLogic.postPullHook(logic.database, serverResponse.pendingKeyRequests, serverResponse.keyResponses)
if (applyResult.didCreateNewActions or cryptoResult.didSendReplies) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "request next sync by crypto handling")
}
requestImportantSync()
}
}
private suspend fun wipeCacheIfUpdated() {

View file

@ -23,6 +23,7 @@ import io.timelimit.android.data.customtypes.ImmutableBitmask
import io.timelimit.android.data.customtypes.ImmutableBitmaskJson
import io.timelimit.android.data.model.*
import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.extensions.base64
import io.timelimit.android.integration.platform.*
import io.timelimit.android.sync.network.ParentPassword
import io.timelimit.android.sync.validation.ListValidation
@ -468,6 +469,144 @@ data class UpdateAppActivitiesAction(
writer.endObject()
}
}
data class UpdateInstalledAppsAction (
val base: ByteArray?,
val diff: ByteArray?,
val wipe: Boolean
): AppLogicAction() {
companion object {
private const val TYPE_VALUE = "UPDATE_INSTALLED_APPS"
private const val BASE = "b"
private const val DIFF = "d"
private const val WIPE = "w"
}
override fun serialize(writer: JsonWriter) {
writer.beginObject()
writer.name(TYPE).value(TYPE_VALUE)
base?.let { writer.name(BASE).value(it.base64()) }
diff?.let { writer.name(DIFF).value(it.base64()) }
writer.name(WIPE).value(wipe)
writer.endObject()
}
}
data class UploadDevicePublicKeyAction (val publicKey: ByteArray): AppLogicAction() {
companion object {
private const val TYPE_VALUE = "UPLOAD_DEVICE_PUBLIC_KEY"
private const val KEY = "key"
}
init {
if (publicKey.size != 32) throw IllegalArgumentException()
}
override fun serialize(writer: JsonWriter) {
writer.beginObject()
writer.name(TYPE).value(TYPE_VALUE)
writer.name(KEY).value(publicKey.base64())
writer.endObject()
}
}
data class SendKeyRequestAction(
val deviceSequenceNumber: Long,
val deviceId: String?,
val categoryId: String?,
val type: Int,
val tempKey: ByteArray,
val signature: ByteArray
): AppLogicAction() {
companion object {
private const val TYPE_VALUE = "SEND_KEY_REQUEST"
private const val SEQ_NUM = "dsn"
private const val DEVICE_ID = "deviceId"
private const val CATEGORY_ID = "categoryId"
private const val DATA_TYPE = "dataType"
private const val TEMP_KEY = "tempKey"
private const val SIGNATURE = "signature"
}
init {
if (tempKey.size != 32 || signature.size != 64) {
throw IllegalArgumentException()
}
if (deviceId != null && categoryId != null) {
throw IllegalArgumentException()
}
deviceId?.also { IdGenerator.assertIdValid(it) }
categoryId?.also { IdGenerator.assertIdValid(it) }
if (!CryptContainerMetadata.isTypeValid(type)) {
throw IllegalArgumentException()
}
}
override fun serialize(writer: JsonWriter) {
writer.beginObject()
writer.name(TYPE).value(TYPE_VALUE)
writer.name(SEQ_NUM).value(deviceSequenceNumber)
deviceId?.let { writer.name(DEVICE_ID).value(it) }
categoryId?.let { writer.name(CATEGORY_ID).value(it) }
writer.name(DATA_TYPE).value(type)
writer.name(TEMP_KEY).value(tempKey.base64())
writer.name(SIGNATURE).value(signature.base64())
writer.endObject()
}
}
data class FinishKeyRequestAction(val deviceSequenceNumber: Long): AppLogicAction() {
companion object {
private const val TYPE_VALUE = "FINISH_KEY_REQUEST"
private const val SEQ_NUM = "dsn"
}
override fun serialize(writer: JsonWriter) {
writer.beginObject()
writer.name(TYPE).value(TYPE_VALUE)
writer.name(SEQ_NUM).value(deviceSequenceNumber)
writer.endObject()
}
}
data class ReplyToKeyRequestAction(
val requestServerSequenceNumber: Long,
val tempKey: ByteArray,
val encryptedKey: ByteArray,
val signature: ByteArray
): AppLogicAction() {
companion object {
private const val TYPE_VALUE = "REPLY_TO_KEY_REQUEST"
private const val REQUEST_SEQUENCE_NUMBER = "rsn"
private const val TEMP_KEY = "tempKey"
private const val ENCRYPTED_KEY = "encryptedKey"
private const val SIGNATURE = "signature"
}
init {
if (tempKey.size != 32 || encryptedKey.size != 16 || signature.size != 64) {
throw IllegalArgumentException()
}
}
override fun serialize(writer: JsonWriter) {
writer.beginObject()
writer.name(TYPE).value(TYPE_VALUE)
writer.name(REQUEST_SEQUENCE_NUMBER).value(requestServerSequenceNumber)
writer.name(TEMP_KEY).value(tempKey.base64())
writer.name(ENCRYPTED_KEY).value(encryptedKey.base64())
writer.name(SIGNATURE).value(signature.base64())
writer.endObject()
}
}
object SignOutAtDeviceAction: AppLogicAction() {
const val TYPE_VALUE = "SIGN_OUT_AT_DEVICE"

View file

@ -38,6 +38,19 @@ import java.io.StringWriter
object ApplyActionUtil {
private const val LOG_TAG = "ApplyActionUtil"
fun addAppLogicActionToDatabaseSync(action: AppLogicAction, database: Database) = database.runInUnobservedTransaction {
database.pendingSyncAction().addSyncActionSync(
PendingSyncAction(
sequenceNumber = database.config().getNextSyncActionSequenceActionAndIncrementIt(),
scheduledForUpload = false,
type = PendingSyncActionType.AppLogic,
userId = "",
integrity = "",
encodedAction = SerializationUtil.serializeAction(action)
)
)
}
suspend fun applyAppLogicAction(
action: AppLogicAction,
appLogic: AppLogic,
@ -163,20 +176,7 @@ object ApplyActionUtil {
}
}
val serializedAction = StringWriter().apply {
JsonWriter(this).apply {
action.serialize(this)
}
}.toString()
database.pendingSyncAction().addSyncActionSync(PendingSyncAction(
sequenceNumber = database.config().getNextSyncActionSequenceActionAndIncrementIt(),
encodedAction = serializedAction,
integrity = "",
scheduledForUpload = false,
type = PendingSyncActionType.AppLogic,
userId = ""
))
addAppLogicActionToDatabaseSync(action, database)
if (action is AddUsedTimeActionVersion2) {
syncUtil.requestVeryUnimportantSync()

View file

@ -26,12 +26,15 @@ class UploadActionsUtil(private val database: Database, private val syncConflict
companion object {
private const val BATCH_SIZE = 25
fun deleteAllVersionNumbersSync(database: Database) {
fun deleteAllVersionNumbersSync(database: Database, wipeCryptoRequests: Boolean = false) {
database.runInTransaction {
database.config().setUserListVersionSync("")
database.config().setDeviceListVersionSync("")
database.device().deleteAllInstalledAppsVersions()
database.category().deleteAllCategoriesVersionNumbers()
database.cryptContainer().deleteAllServerVersionNumbers()
if (wipeCryptoRequests) { database.cryptContainerKeyRequest().deleteAll() }
}
}
}

View file

@ -388,6 +388,11 @@ object LocalDatabaseAppLogicActionDispatcher {
database.childTasks().updateItemSync(task.copy(pendingRequest = true))
}
is UpdateInstalledAppsAction -> {/* nothing to do, this is only for the server */}
is UploadDevicePublicKeyAction -> {/* nothing to do, this is only for the server */}
is SendKeyRequestAction -> {/* nothing to do, this is only for the server */}
is FinishKeyRequestAction -> {/* nothing to do, this is only for the server */}
is ReplyToKeyRequestAction -> {/* nothing to do, this is only for the server */}
}.let { }
}
}

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -17,16 +17,15 @@ package io.timelimit.android.sync.network
import android.util.JsonWriter
import io.timelimit.android.data.Database
import io.timelimit.android.livedata.map
import io.timelimit.android.livedata.waitForNonNullValue
import java.util.*
import kotlin.collections.HashMap
data class ClientDataStatus(
val deviceListVersion: String,
val installedAppsVersionsByDeviceId: Map<String, String>,
val deviceDetailData: Map<String, DeviceDataStatus>,
val categories: Map<String, CategoryDataStatus>,
val userListVersion: String
val userListVersion: String,
val lastKeyRequestServerSequence: Long?,
val lastKeyResponseServerSequence: Long?
) {
companion object {
private const val DEVICES = "devices"
@ -34,44 +33,59 @@ data class ClientDataStatus(
private const val CATEGORIES = "categories"
private const val USERS = "users"
private const val CLIENT_LEVEL = "clientLevel"
private const val CLIENT_LEVEL_VALUE = 3
private const val DEVICES_DETAIL = "devicesDetail"
private const val LAST_KEY_REQUEST_SEQUENCE = "kri"
private const val LAST_KEY_RESPONSE_SEQUENCE = "kr"
private const val CLIENT_LEVEL_VALUE = 4
val empty = ClientDataStatus(
deviceListVersion = "",
installedAppsVersionsByDeviceId = Collections.emptyMap(),
categories = Collections.emptyMap(),
userListVersion = ""
installedAppsVersionsByDeviceId = emptyMap(),
deviceDetailData = emptyMap(),
categories = emptyMap(),
userListVersion = "",
lastKeyRequestServerSequence = null,
lastKeyResponseServerSequence = null
)
suspend fun getClientDataStatusAsync(database: Database): ClientDataStatus {
return ClientDataStatus(
deviceListVersion = database.config().getDeviceListVersion().waitForNonNullValue(),
installedAppsVersionsByDeviceId = database.device().getInstalledAppsVersions().map {
val devicesWithAppVersions = it
val result = HashMap<String, String>()
fun getClientDataStatusSync(database: Database): ClientDataStatus {
return database.runInUnobservedTransaction {
ClientDataStatus(
deviceListVersion = database.config().getDeviceListVersionSync(),
installedAppsVersionsByDeviceId = database.device()
.getInstalledAppsVersionsSync()
.associateBy { it.deviceId }
.mapValues { it.value.installedAppsVersions },
deviceDetailData = if (database.config().getServerApiLevelSync() >= 4)
database.device().getDeviceDetailDataSync()
.associateBy { it.deviceId }
.mapValues {
val item = it.value
devicesWithAppVersions.forEach { result[it.deviceId] = it.installedAppsVersions }
Collections.unmodifiableMap(result)
}.waitForNonNullValue(),
categories = database.category().getCategoriesWithVersionNumbers().map {
val categoriesWithVersions = it
val result = HashMap<String, CategoryDataStatus>()
categoriesWithVersions.forEach {
result[it.categoryId] = CategoryDataStatus(
baseVersion = it.baseVersion,
assignedAppsVersion = it.assignedAppsVersion,
timeLimitRulesVersion = it.timeLimitRulesVersion,
usedTimeItemsVersion = it.usedTimeItemsVersion,
taskListVersion = it.taskListVersion
DeviceDataStatus(
appsBaseVersion = item.appBaseVersion,
appsDiffVersion = item.appDiffVersion
)
}
else emptyMap(),
categories = database.category().getCategoriesWithVersionNumbersSybc()
.associateBy { it.categoryId }
.mapValues {
val item = it.value
Collections.unmodifiableMap(result)
}.waitForNonNullValue(),
userListVersion = database.config().getUserListVersion().waitForNonNullValue()
CategoryDataStatus(
baseVersion = item.baseVersion,
assignedAppsVersion = item.assignedAppsVersion,
timeLimitRulesVersion = item.timeLimitRulesVersion,
usedTimeItemsVersion = item.usedTimeItemsVersion,
taskListVersion = item.taskListVersion
)
},
userListVersion = database.config().getUserListVersionSync(),
lastKeyRequestServerSequence = database.config().getLastServerKeyRequestSequenceSync(),
lastKeyResponseServerSequence = database.config().getLastServerKeyResponseSequenceSync()
)
}
}
}
@ -89,6 +103,16 @@ data class ClientDataStatus(
}
writer.endObject()
if (deviceDetailData.isNotEmpty()) {
writer.name(DEVICES_DETAIL)
writer.beginObject()
deviceDetailData.entries.forEach {
writer.name(it.key)
it.value.serialize(writer)
}
writer.endObject()
}
writer.name(CATEGORIES)
writer.beginObject()
categories.entries.forEach {
@ -97,6 +121,9 @@ data class ClientDataStatus(
}
writer.endObject()
lastKeyRequestServerSequence?.let { writer.name(LAST_KEY_REQUEST_SEQUENCE).value(it) }
lastKeyResponseServerSequence?.let { writer.name(LAST_KEY_RESPONSE_SEQUENCE).value(it) }
writer.endObject()
}
}
@ -129,3 +156,22 @@ data class CategoryDataStatus(
writer.endObject()
}
}
data class DeviceDataStatus (
val appsBaseVersion: String?,
val appsDiffVersion: String?
) {
companion object {
private const val APPS_BASE_VERSION = "appsB"
private const val APPS_DIFF_VERSION = "appsD"
}
fun serialize(writer: JsonWriter) {
writer.beginObject()
appsBaseVersion?.let { writer.name(APPS_BASE_VERSION).value(it) }
appsDiffVersion?.let { writer.name(APPS_DIFF_VERSION).value(it) }
writer.endObject()
}
}

View file

@ -23,6 +23,7 @@ import io.timelimit.android.data.customtypes.ImmutableBitmask
import io.timelimit.android.data.customtypes.ImmutableBitmaskJson
import io.timelimit.android.data.model.*
import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.extensions.parseBase64
import io.timelimit.android.extensions.parseList
import io.timelimit.android.integration.platform.*
import io.timelimit.android.sync.actions.AppActivityItem
@ -34,6 +35,7 @@ import kotlin.collections.ArrayList
data class ServerDataStatus(
val newDeviceList: ServerDeviceList?,
val updatedExtendedDeviceData: List<ServerExtendedDeviceData>,
val newInstalledApps: List<ServerInstalledAppsData>,
val removedCategories: List<String>,
val newCategoryBaseData: List<ServerUpdatedCategoryBaseData>,
@ -42,12 +44,15 @@ data class ServerDataStatus(
val newCategoryTimeLimitRules: List<ServerUpdatedTimeLimitRules>,
val newCategoryTasks: List<ServerUpdatedCategoryTasks>,
val newUserList: ServerUserList?,
val pendingKeyRequests: List<ServerKeyRequest>,
val keyResponses: List<ServerKeyResponse>,
val fullVersionUntil: Long,
val message: String?,
val apiLevel: Int
) {
companion object {
private const val NEW_DEVICE_LIST = "devices"
private const val UPDATED_EXTENDED_DEVICE_DATA = "devices2"
private const val NEW_INSTALLED_APPS = "apps"
private const val REMOVED_CATEGORIES = "rmCategories"
private const val NEW_CATEGORIES_BASE_DATA = "categoryBase"
@ -56,12 +61,15 @@ data class ServerDataStatus(
private const val NEW_CATEGORY_TIME_LIMIT_RULES = "rules"
private const val NEW_CATEGORY_TASKS = "tasks"
private const val NEW_USER_LIST = "users"
private const val PENDING_KEY_REQUESTS = "krq"
private const val KEY_RESPONSES = "kr"
private const val FULL_VERSION_UNTIL = "fullVersion"
private const val MESSAGE = "message"
private const val API_LEVEL = "apiLevel"
fun parse(reader: JsonReader): ServerDataStatus {
var newDeviceList: ServerDeviceList? = null
var updatedExtendedDeviceData: List<ServerExtendedDeviceData> = emptyList()
var newInstalledApps: List<ServerInstalledAppsData> = Collections.emptyList()
var removedCategories: List<String> = Collections.emptyList()
var newCategoryBaseData: List<ServerUpdatedCategoryBaseData> = Collections.emptyList()
@ -70,6 +78,8 @@ data class ServerDataStatus(
var newCategoryTimeLimitRules: List<ServerUpdatedTimeLimitRules> = Collections.emptyList()
var newCategoryTasks: List<ServerUpdatedCategoryTasks> = emptyList()
var newUserList: ServerUserList? = null
var pendingKeyRequests = emptyList<ServerKeyRequest>()
var keyResponses = emptyList<ServerKeyResponse>()
var fullVersionUntil: Long? = null
var message: String? = null
var apiLevel = 0
@ -78,6 +88,7 @@ data class ServerDataStatus(
while (reader.hasNext()) {
when(reader.nextName()) {
NEW_DEVICE_LIST -> newDeviceList = ServerDeviceList.parse(reader)
UPDATED_EXTENDED_DEVICE_DATA -> updatedExtendedDeviceData = parseJsonArray(reader, { ServerExtendedDeviceData.parse(reader) })
NEW_INSTALLED_APPS -> newInstalledApps = ServerInstalledAppsData.parseList(reader)
REMOVED_CATEGORIES -> removedCategories = parseJsonStringArray(reader)
NEW_CATEGORIES_BASE_DATA -> newCategoryBaseData = ServerUpdatedCategoryBaseData.parseList(reader)
@ -86,6 +97,8 @@ data class ServerDataStatus(
NEW_CATEGORY_TIME_LIMIT_RULES -> newCategoryTimeLimitRules = ServerUpdatedTimeLimitRules.parseList(reader)
NEW_CATEGORY_TASKS -> newCategoryTasks = ServerUpdatedCategoryTasks.parseList(reader)
NEW_USER_LIST -> newUserList = ServerUserList.parse(reader)
PENDING_KEY_REQUESTS -> pendingKeyRequests = ServerKeyRequest.parseList(reader)
KEY_RESPONSES -> keyResponses = ServerKeyResponse.parseList(reader)
FULL_VERSION_UNTIL -> fullVersionUntil = reader.nextLong()
MESSAGE -> message = reader.nextString()
API_LEVEL -> apiLevel = reader.nextInt()
@ -96,6 +109,7 @@ data class ServerDataStatus(
return ServerDataStatus(
newDeviceList = newDeviceList,
updatedExtendedDeviceData = updatedExtendedDeviceData,
newInstalledApps = newInstalledApps,
removedCategories = removedCategories,
newCategoryBaseData = newCategoryBaseData,
@ -104,6 +118,8 @@ data class ServerDataStatus(
newCategoryTimeLimitRules = newCategoryTimeLimitRules,
newCategoryTasks = newCategoryTasks,
newUserList = newUserList,
pendingKeyRequests = pendingKeyRequests,
keyResponses = keyResponses,
fullVersionUntil = fullVersionUntil!!,
message = message,
apiLevel = apiLevel
@ -299,7 +315,8 @@ data class ServerDeviceData(
val wasAccessibilityServiceEnabled: Boolean,
val enableActivityLevelBlocking: Boolean,
val qOrLater: Boolean,
val manipulationFlags: Long
val manipulationFlags: Long,
val publicKey: ByteArray?
) {
companion object {
private const val DEVICE_ID = "deviceId"
@ -333,6 +350,7 @@ data class ServerDeviceData(
private const val ENABLE_ACTIVITY_LEVEL_BLOCKING = "activityLevelBlocking"
private const val Q_OR_LATER = "qOrLater"
private const val MANIPULATION_FLAGS = "mFlags"
private const val PUBLIC_KEY = "pk"
fun parse(reader: JsonReader): ServerDeviceData {
var deviceId: String? = null
@ -366,6 +384,7 @@ data class ServerDeviceData(
var enableActivityLevelBlocking = false
var qOrLater = false
var manipulationFlags = 0L
var publicKey: ByteArray? = null
reader.beginObject()
while (reader.hasNext()) {
@ -401,6 +420,7 @@ data class ServerDeviceData(
ENABLE_ACTIVITY_LEVEL_BLOCKING -> enableActivityLevelBlocking = reader.nextBoolean()
Q_OR_LATER -> qOrLater = reader.nextBoolean()
MANIPULATION_FLAGS -> manipulationFlags = reader.nextLong()
PUBLIC_KEY -> publicKey = reader.nextString().parseBase64()
else -> reader.skipValue()
}
}
@ -437,7 +457,8 @@ data class ServerDeviceData(
wasAccessibilityServiceEnabled = wasAccessibilityServiceEnabled!!,
enableActivityLevelBlocking = enableActivityLevelBlocking,
qOrLater = qOrLater,
manipulationFlags = manipulationFlags
manipulationFlags = manipulationFlags,
publicKey = publicKey
)
}
@ -1065,3 +1086,182 @@ data class ServerInstalledAppsData(
fun parseList(reader: JsonReader) = parseJsonArray(reader) { parse(reader) }
}
}
data class ServerExtendedDeviceData(
val deviceId: String,
val appsBase: ServerCryptContainer?,
val appsDiff: ServerCryptContainer?
) {
companion object {
private const val DEVICE_ID = "deviceId"
private const val APPS_BASE = "appsBase"
private const val APPS_DIFF = "appsDiff"
fun parse(reader: JsonReader): ServerExtendedDeviceData {
var deviceId: String? = null
var appsBase: ServerCryptContainer? = null
var appsDiff: ServerCryptContainer? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
DEVICE_ID -> deviceId = reader.nextString()
APPS_BASE -> appsBase = ServerCryptContainer.parse(reader)
APPS_DIFF -> appsDiff = ServerCryptContainer.parse(reader)
else -> reader.skipValue()
}
}
reader.endObject()
return ServerExtendedDeviceData(
deviceId = deviceId!!,
appsBase = appsBase,
appsDiff = appsDiff
)
}
}
}
data class ServerCryptContainer(
val version: String,
val data: ByteArray
) {
companion object {
private const val VERSION = "version"
private const val DATA = "data"
fun parse(reader: JsonReader): ServerCryptContainer {
var version: String? = null
var data: ByteArray? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
VERSION -> version = reader.nextString()
DATA -> data = reader.nextString().parseBase64()
else -> reader.skipValue()
}
}
reader.endObject()
return ServerCryptContainer(
version = version!!,
data = data!!
)
}
}
}
data class ServerKeyRequest(
val serverRequestSequenceNumber: Long,
val senderDeviceId: String,
val senderSequenceNumber: Long,
val tempKey: ByteArray,
val deviceId: String?,
val categoryId: String?,
val type: Int,
val signature: ByteArray
) {
companion object {
private const val SERVER_REQUEST_SEQUENCE_NUMBER = "srvSeq"
private const val SENDER_DEVICE_ID = "senId"
private const val SENDER_SEQUENCE_NUMBER = "senSeq"
private const val DEVICE_ID = "deviceId"
private const val CATEGORY_ID = "categoryId"
private const val TYPE = "type"
private const val TEMP_KEY = "tempKey"
private const val SIGNATURE = "signature"
fun parse(reader: JsonReader): ServerKeyRequest {
var serverRequestSequenceNumber: Long? = null
var senderDeviceId: String? = null
var senderSequenceNumber: Long? = null
var tempKey: ByteArray? = null
var deviceId: String? = null
var categoryId: String? = null
var type: Int? = null
var signature: ByteArray? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
SERVER_REQUEST_SEQUENCE_NUMBER -> serverRequestSequenceNumber = reader.nextLong()
SENDER_DEVICE_ID -> senderDeviceId = reader.nextString()
SENDER_SEQUENCE_NUMBER -> senderSequenceNumber = reader.nextLong()
TEMP_KEY -> tempKey = reader.nextString().parseBase64()
DEVICE_ID -> deviceId = reader.nextString()
CATEGORY_ID -> categoryId = reader.nextString()
TYPE -> type = reader.nextInt()
SIGNATURE -> signature = reader.nextString().parseBase64()
else -> reader.skipValue()
}
}
reader.endObject()
return ServerKeyRequest(
serverRequestSequenceNumber = serverRequestSequenceNumber!!,
senderDeviceId = senderDeviceId!!,
senderSequenceNumber = senderSequenceNumber!!,
tempKey = tempKey!!,
deviceId = deviceId,
categoryId = categoryId,
type = type!!,
signature = signature!!
)
}
fun parseList(reader: JsonReader): List<ServerKeyRequest> = parseJsonArray(reader) { parse(reader) }
}
}
data class ServerKeyResponse(
val serverResponseSequenceNumber: Long,
val senderDeviceId: String,
val requestSequenceId: Long,
val tempKey: ByteArray,
val encryptedKey: ByteArray,
val signature: ByteArray
) {
companion object {
private const val SERVER_RESPONSE_SEQUENCE = "srvSeq"
private const val SENDER_DEVICE_ID = "sender"
private const val REQUEST_SEQUENCE_ID = "rqSeq"
private const val TEMP_KEY = "tempKey"
private const val ENCRYPTED_KEY = "cryptKey"
private const val SIGNATURE = "signature"
fun parse(reader: JsonReader): ServerKeyResponse {
var serverResponseSequenceNumber: Long? = null
var senderDeviceId: String? = null
var requestSequenceId: Long? = null
var tempKey: ByteArray? = null
var encryptedKey: ByteArray? = null
var signature: ByteArray? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
SERVER_RESPONSE_SEQUENCE -> serverResponseSequenceNumber = reader.nextLong()
SENDER_DEVICE_ID -> senderDeviceId = reader.nextString()
REQUEST_SEQUENCE_ID -> requestSequenceId = reader.nextLong()
TEMP_KEY -> tempKey = reader.nextString().parseBase64()
ENCRYPTED_KEY -> encryptedKey = reader.nextString().parseBase64()
SIGNATURE -> signature = reader.nextString().parseBase64()
else -> reader.skipValue()
}
}
reader.endObject()
return ServerKeyResponse(
serverResponseSequenceNumber = serverResponseSequenceNumber!!,
senderDeviceId = senderDeviceId!!,
requestSequenceId = requestSequenceId!!,
tempKey = tempKey!!,
encryptedKey = encryptedKey!!,
signature = signature!!
)
}
fun parseList(reader: JsonReader) = parseJsonArray(reader) { parse(reader) }
}
}

View file

@ -195,6 +195,12 @@ data class DiagnoseExperimentalFlagItem(
enableFlags = ExperimentalFlags.STRICT_OVERLAY_CHECKING,
disableFlags = ExperimentalFlags.STRICT_OVERLAY_CHECKING,
enable = { true }
),
DiagnoseExperimentalFlagItem(
label = R.string.diagnose_exf_dls,
enableFlags = ExperimentalFlags.DISABLE_LEGACY_APP_SENDING,
disableFlags = ExperimentalFlags.DISABLE_LEGACY_APP_SENDING,
enable = { true }
)
)
}

View file

@ -55,7 +55,7 @@ class DiagnoseSyncFragment : Fragment(), FragmentWithCustomTitle {
binding.clearCacheBtn.setOnClickListener {
Threads.database.execute {
UploadActionsUtil.deleteAllVersionNumbersSync(logic.database)
UploadActionsUtil.deleteAllVersionNumbersSync(logic.database, wipeCryptoRequests = true)
}
Toast.makeText(requireContext(), R.string.diagnose_sync_btn_clear_cache_toast, Toast.LENGTH_SHORT).show()

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -24,15 +24,15 @@ import androidx.fragment.app.Fragment
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import androidx.lifecycle.switchMap
import androidx.navigation.Navigation
import io.timelimit.android.R
import io.timelimit.android.crypto.Curve25519
import io.timelimit.android.crypto.HexString
import io.timelimit.android.data.model.Device
import io.timelimit.android.databinding.FragmentManageDeviceBinding
import io.timelimit.android.extensions.safeNavigate
import io.timelimit.android.livedata.ignoreUnchanged
import io.timelimit.android.livedata.liveDataFromNonNullValue
import io.timelimit.android.livedata.map
import io.timelimit.android.livedata.switchMap
import io.timelimit.android.livedata.*
import io.timelimit.android.logic.AppLogic
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.logic.RealTime
@ -150,6 +150,26 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle {
binding.isThisDevice = it
})
val signingKey = isThisDevice.switchMap { isLocalDevice ->
if (isLocalDevice) {
logic.fullVersion.isLocalMode.switchMap { isLocalMode ->
if (isLocalMode) liveDataFromNullableValue(null)
else
logic.database.config().getSigningKeyAsync().map {
if (it != null) Curve25519.getPublicKey(it)
else null
}
}
} else {
logic.database.deviceKey().getLive(args.deviceId).map {
it?.publicKey
}
}
}.map {
if (it == null) null
else HexString.toHex(it)
}
ManageDeviceIntroduction.bind(
view = binding.introduction,
database = logic.database,
@ -182,6 +202,8 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle {
binding.userCardText = it?.name ?: getString(R.string.manage_device_current_user_none)
})
signingKey.observe(viewLifecycleOwner) { binding.devicePublicKey = it }
return binding.root
}

View file

@ -0,0 +1,59 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
syntax = "proto3";
package io.timelimit.proto.applist;
message InstalledAppProto {
string package_name = 1;
string title = 2;
bool is_launchable = 3;
Recommendation recommendation = 4;
enum Recommendation {
NONE = 0;
WHITELIST = 1;
BLACKLIST = 2;
}
}
message InstalledAppActivityProto {
string package_name = 1;
string class_name = 2;
string title = 3;
}
message RemovedAppActivityProto {
string package_name = 1;
string class_name = 2;
}
message InstalledAppsProto {
repeated InstalledAppProto apps = 1;
repeated InstalledAppActivityProto activities = 2;
}
message InstalledAppsDifferenceProto {
InstalledAppsProto added = 1;
repeated string removed_packages = 2;
repeated RemovedAppActivityProto removed_activities = 3;
}
message SavedAppsDifferenceProto {
InstalledAppsDifferenceProto apps = 1;
int64 base_generation = 2;
int64 base_counter = 3;
}

View file

@ -50,8 +50,13 @@
name="handlers"
type="io.timelimit.android.ui.manage.device.manage.ManageDeviceFragmentHandlers" />
<variable
name="devicePublicKey"
type="String" />
<import type="android.view.View" />
<import type="io.timelimit.android.BuildConfig" />
<import type="android.text.TextUtils" />
</data>
<androidx.coordinatorlayout.widget.CoordinatorLayout
@ -107,6 +112,14 @@
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:visibility="@{TextUtils.isEmpty(devicePublicKey) ? View.GONE : View.VISIBLE}"
android:text="@{@string/manage_device_signing_key(devicePublicKey)}"
android:textAppearance="?android:textAppearanceMedium"
tools:text="Signing Key: 1234"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:visibility="@{safeUnbox(isThisDevice) ? View.VISIBLE : View.GONE}"
android:textAppearance="?android:textAppearanceMedium"

View file

@ -531,6 +531,7 @@
<string name="diagnose_exf_esb">Overlay und Home-Button nicht zum Sperren verwenden</string>
<string name="diagnose_exf_srn">Toasts zur Synchronisation anzeigen</string>
<string name="diagnose_exf_soc">strengere Prüfung der Überlagerungs-Berechtigung aktivieren</string>
<string name="diagnose_exf_dls">deaktiviere Senden der App-Liste im alten Format</string>
<string name="diagnose_bg_task_loop_ex">Hintergrundaufgabenschleifenfehler</string>
@ -894,6 +895,7 @@
<string name="manage_device_added_at">Hinzugefügt: %s</string>
<string name="manage_device_is_this_device">Das ist das Gerät, das Sie gerade vor sich haben</string>
<string name="manage_device_signing_key">Signaturschlüssel: %s</string>
<string name="manage_device_current_user_title">Benutzer des Geräts</string>
<string name="manage_device_current_user_none">Keine Angabe - keine Begrenzungen</string>
@ -1144,6 +1146,9 @@
<string name="notification_channel_reset_title">Reset-Benachrichtigung</string>
<string name="notification_channel_reset_text">Zeigt eine Benachrichtigung an, wenn TimeLimit an diesem Gerät zurückgesetzt wurde</string>
<string name="notification_channel_new_device_title">neue Geräte</string>
<string name="notification_channel_new_device_description">Zeigt eine Benachrichtigung an, wenn TimeLimit mit Vernetzung verwendet wird und ein neues Gerät verknüpft wurde</string>
<string name="notification_filter_not_blocked_title">TimeLimit hat eine Benachrichtigung blockiert</string>
<string name="notification_filter_blocking_failed_title">TimeLimit konnte eine Benachrichtigung nicht blockieren</string>
@ -1157,6 +1162,8 @@
<string name="notification_background_sync_title">TimeLimit ist aktiv</string>
<string name="notification_background_sync_text">eine Synchronisation läuft</string>
<string name="notification_new_device_title">Gerät hinzugefügt</string>
<string name="obsolete_message">Sie verwenden TimeLimit auf einer älteren Android-Version.
Das kann funktionieren, aber es wird nicht empfohlen.
</string>

View file

@ -105,6 +105,8 @@
(<a href="https://github.com/signalapp/curve25519-java/blob/master/LICENSE">GNU General Public License v3.0</a>)
\n<a href="https://github.com/zxing/zxing">ZXing</a>
(<a href="https://github.com/zxing/zxing/blob/master/LICENSE">Apache License 2.0</a>)
\n<a href="https://github.com/square/wire/blob/master/README.md">Wire</a>
(<a href="https://github.com/square/wire/blob/master/LICENSE.txt">Apache License 2.0</a>)
</string>
<string name="about_diagnose_title">Error diagnose</string>
@ -581,6 +583,7 @@
<string name="diagnose_exf_esb">Do not use a overlay or the home button for blocking</string>
<string name="diagnose_exf_srn">Show sync related toasts</string>
<string name="diagnose_exf_soc">Enable strict overlay permission check</string>
<string name="diagnose_exf_dls">Disable sending the App List with the legacy encoding</string>
<string name="diagnose_bg_task_loop_ex">Background task loop exception</string>
@ -937,6 +940,7 @@
<string name="manage_device_added_at">Added: %s</string>
<string name="manage_device_is_this_device">This is the device which is in front of you</string>
<string name="manage_device_signing_key">Signing Key: %s</string>
<string name="manage_device_current_user_title">User of the device</string>
<string name="manage_device_current_user_none">No selection - no limits</string>
@ -1186,6 +1190,9 @@
<string name="notification_channel_reset_title">Reset Notification</string>
<string name="notification_channel_reset_text">Shows a notification if TimeLimit was reset at this device</string>
<string name="notification_channel_new_device_title">New Device</string>
<string name="notification_channel_new_device_description">Shows a notification if the connected mode is used and a new device was linked</string>
<string name="notification_filter_not_blocked_title">TimeLimit has blocked a notification</string>
<string name="notification_filter_blocking_failed_title">TimeLimit could not block a notification</string>
@ -1199,6 +1206,8 @@
<string name="notification_background_sync_title">TimeLimit is running</string>
<string name="notification_background_sync_text">a background synchronisation is in progress</string>
<string name="notification_new_device_title">Device added</string>
<string name="obsolete_message">You are using TimeLimit at a obsolete Android version.
Although this can work, it is not recommend.
</string>

View file

@ -26,6 +26,7 @@ buildscript {
classpath 'com.android.tools.build:gradle:7.2.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.4.2"
classpath 'com.squareup.wire:wire-gradle-plugin:4.4.0'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files