SyncAlert.kt

3.4 KB · raw

1package com.peachpasswords.sync23import com.peachpasswords.vault.Tombstone4import com.peachpasswords.vault.VaultEntry5import kotlinx.serialization.Serializable67private const val MAX_SYNC_ALERTS = 20089@Serializable10enum class SyncAlertType {11    REMOTE_PASSWORD_CHANGE,12    REMOTE_DELETE,13    DEVICE_KEY_ROTATED,14}1516@Serializable17data class SyncAlert(18    val type: SyncAlertType,19    val entryId: String? = null,20    val entryTitle: String? = null,21    val deviceName: String? = null,22    val timestamp: Long,23)2425interface SyncAlertRecorder {26    fun record(alerts: List<SyncAlert>)27}2829internal fun boundedSyncAlerts(existing: List<SyncAlert>, incoming: List<SyncAlert>): List<SyncAlert> =30    (existing + incoming).takeLast(MAX_SYNC_ALERTS)3132internal fun remoteEntrySyncAlerts(33    localEntries: List<VaultEntry>,34    remoteEntries: List<VaultEntry>,35    deviceName: String,36    nowMs: Long,37): List<SyncAlert> {38    val localById = localEntries.associateBy { it.id }39    return remoteEntries.mapNotNull { remote ->40        val local = localById[remote.id] ?: return@mapNotNull null41        if (remote.modifiedAt <= local.modifiedAt || remote.deletedAt != null) return@mapNotNull null42        if (remote.password == local.password) return@mapNotNull null43        SyncAlert(44            type = SyncAlertType.REMOTE_PASSWORD_CHANGE,45            entryId = remote.id,46            entryTitle = remote.title.ifBlank { local.title },47            deviceName = deviceName,48            timestamp = nowMs,49        )50    }51}5253internal fun remoteTombstoneSyncAlerts(54    localEntries: List<VaultEntry>,55    remoteTombstones: List<Tombstone>,56    deviceName: String,57    nowMs: Long,58): List<SyncAlert> {59    val localById = localEntries.associateBy { it.id }60    return remoteTombstones.mapNotNull { tombstone ->61        val local = localById[tombstone.id] ?: return@mapNotNull null62        if (tombstone.deletedAt < local.modifiedAt) return@mapNotNull null63        SyncAlert(64            type = SyncAlertType.REMOTE_DELETE,65            entryId = local.id,66            entryTitle = local.title,67            deviceName = deviceName,68            timestamp = nowMs,69        )70    }71}7273internal fun deviceKeyRotationAlert(74    existing: PairedDevice,75    incoming: ManifestDeviceEntry,76    nowMs: Long,77): SyncAlert? {78    val nextLongTerm = incoming.longTermPublicKeyHex.lowercase()79    val nextSigning = incoming.signingPublicKeyHex.lowercase()80    val longTermChanged = existing.longTermPublicKeyHex?.let {81        !CryptoCompare.hex(it, nextLongTerm)82    } == true83    val signingChanged = existing.signingPublicKeyHex?.let {84        !CryptoCompare.hex(it, nextSigning)85    } == true86    if (!longTermChanged && !signingChanged) return null87    return SyncAlert(88        type = SyncAlertType.DEVICE_KEY_ROTATED,89        deviceName = incoming.deviceName.ifBlank { existing.deviceName ?: "paired device" },90        timestamp = nowMs,91    )92}9394fun syncAlertMessage(alert: SyncAlert): String {95    val device = alert.deviceName?.takeIf { it.isNotBlank() } ?: "paired device"96    val title = alert.entryTitle?.takeIf { it.isNotBlank() } ?: "an entry"97    return when (alert.type) {98        SyncAlertType.REMOTE_PASSWORD_CHANGE -> "$device changed the password for $title"99        SyncAlertType.REMOTE_DELETE -> "$device deleted $title"100        SyncAlertType.DEVICE_KEY_ROTATED ->101            "$device's sync key changed - if you didn't reinstall Peach there, rotate your sync identity"102    }103}