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}