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 DEVICE_REVOKED,15}1617@Serializable18data class SyncAlert(19 val type: SyncAlertType,20 val entryId: String? = null,21 val entryTitle: String? = null,22 val deviceName: String? = null,23 val timestamp: Long,24)2526interface SyncAlertRecorder {27 fun record(alerts: List<SyncAlert>)28}2930internal fun boundedSyncAlerts(existing: List<SyncAlert>, incoming: List<SyncAlert>): List<SyncAlert> =31 (existing + incoming).takeLast(MAX_SYNC_ALERTS)3233internal fun remoteEntrySyncAlerts(34 localEntries: List<VaultEntry>,35 remoteEntries: List<VaultEntry>,36 deviceName: String,37 nowMs: Long,38): List<SyncAlert> {39 val localById = localEntries.associateBy { it.id }40 return remoteEntries.mapNotNull { remote ->41 val local = localById[remote.id] ?: return@mapNotNull null42 if (remote.modifiedAt <= local.modifiedAt || remote.deletedAt != null) return@mapNotNull null43 if (remote.password == local.password) return@mapNotNull null44 SyncAlert(45 type = SyncAlertType.REMOTE_PASSWORD_CHANGE,46 entryId = remote.id,47 entryTitle = remote.title.ifBlank { local.title },48 deviceName = deviceName,49 timestamp = nowMs,50 )51 }52}5354internal fun remoteTombstoneSyncAlerts(55 localEntries: List<VaultEntry>,56 remoteTombstones: List<Tombstone>,57 deviceName: String,58 nowMs: Long,59): List<SyncAlert> {60 val localById = localEntries.associateBy { it.id }61 return remoteTombstones.mapNotNull { tombstone ->62 val local = localById[tombstone.id] ?: return@mapNotNull null63 if (tombstone.deletedAt < local.modifiedAt) return@mapNotNull null64 SyncAlert(65 type = SyncAlertType.REMOTE_DELETE,66 entryId = local.id,67 entryTitle = local.title,68 deviceName = deviceName,69 timestamp = nowMs,70 )71 }72}7374internal fun deviceKeyRotationAlert(75 existing: PairedDevice,76 incoming: ManifestDeviceEntry,77 nowMs: Long,78): SyncAlert? {79 val nextLongTerm = incoming.longTermPublicKeyHex.lowercase()80 val nextSigning = incoming.signingPublicKeyHex.lowercase()81 val longTermChanged = existing.longTermPublicKeyHex?.let {82 !CryptoCompare.hex(it, nextLongTerm)83 } == true84 val signingChanged = existing.signingPublicKeyHex?.let {85 !CryptoCompare.hex(it, nextSigning)86 } == true87 if (!longTermChanged && !signingChanged) return null88 89 90 91 92 return SyncAlert(93 type = SyncAlertType.DEVICE_KEY_ROTATED,94 deviceName = existing.deviceName?.takeIf { it.isNotBlank() }95 ?: incoming.deviceName.ifBlank { "paired device" },96 timestamp = nowMs,97 )98}99100fun syncAlertMessage(alert: SyncAlert): String {101 val device = alert.deviceName?.takeIf { it.isNotBlank() } ?: "paired device"102 val title = alert.entryTitle?.takeIf { it.isNotBlank() } ?: "an entry"103 return when (alert.type) {104 SyncAlertType.REMOTE_PASSWORD_CHANGE -> "$device changed the password for $title"105 SyncAlertType.REMOTE_DELETE -> "$device deleted $title"106 SyncAlertType.DEVICE_REVOKED ->107 "$device revoked a paired device"108 SyncAlertType.DEVICE_KEY_ROTATED ->109 "$device's sync key changed - if you didn't reinstall Peach there, rotate your sync identity"110 }111}