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?.lowercase()?.let { it != nextLongTerm } == true81    val signingChanged = existing.signingPublicKeyHex?.lowercase()?.let { it != nextSigning } == true82    if (!longTermChanged && !signingChanged) return null83    return SyncAlert(84        type = SyncAlertType.DEVICE_KEY_ROTATED,85        deviceName = incoming.deviceName.ifBlank { existing.deviceName ?: "paired device" },86        timestamp = nowMs,87    )88}8990fun syncAlertMessage(alert: SyncAlert): String {91    val device = alert.deviceName?.takeIf { it.isNotBlank() } ?: "paired device"92    val title = alert.entryTitle?.takeIf { it.isNotBlank() } ?: "an entry"93    return when (alert.type) {94        SyncAlertType.REMOTE_PASSWORD_CHANGE -> "$device changed the password for $title"95        SyncAlertType.REMOTE_DELETE -> "$device deleted $title"96        SyncAlertType.DEVICE_KEY_ROTATED ->97            "$device's sync key changed - if you didn't reinstall Peach there, rotate your sync identity"98    }99}