1package com.peachpasswords.sync23import kotlinx.serialization.Serializable45@Serializable6enum class SyncAlertType {7 REMOTE_PASSWORD_CHANGE,8 REMOTE_DELETE,9 DEVICE_KEY_ROTATED,10 DEVICE_REVOKED,11}1213@Serializable14data class SyncAlert(15 val type: SyncAlertType,16 val entryId: String? = null,17 val entryTitle: String? = null,18 val deviceName: String? = null,19 val timestamp: Long,20)2122interface SyncAlertRecorder {23 fun record(alerts: List<SyncAlert>)24}2526internal fun isDeviceTrustAlert(alert: SyncAlert): Boolean =27 alert.type == SyncAlertType.DEVICE_KEY_ROTATED ||28 alert.type == SyncAlertType.DEVICE_REVOKED2930internal fun deviceKeyRotationAlert(31 existing: PairedDevice,32 incoming: ManifestDeviceEntry,33 nowMs: Long,34): SyncAlert? {35 val nextLongTerm = incoming.longTermPublicKeyHex.lowercase()36 val nextSigning = incoming.signingPublicKeyHex.lowercase()37 val longTermChanged = existing.longTermPublicKeyHex?.let {38 !CryptoCompare.hex(it, nextLongTerm)39 } == true40 val signingChanged = existing.signingPublicKeyHex?.let {41 !CryptoCompare.hex(it, nextSigning)42 } == true43 if (!longTermChanged && !signingChanged) return null44 45 46 47 48 return SyncAlert(49 type = SyncAlertType.DEVICE_KEY_ROTATED,50 deviceName = existing.deviceName?.takeIf { it.isNotBlank() }51 ?: incoming.deviceName.ifBlank { "paired device" },52 timestamp = nowMs,53 )54}5556fun syncAlertMessage(alert: SyncAlert): String {57 val device = alert.deviceName?.takeIf { it.isNotBlank() } ?: "paired device"58 val title = alert.entryTitle?.takeIf { it.isNotBlank() } ?: "an entry"59 return when (alert.type) {60 SyncAlertType.REMOTE_PASSWORD_CHANGE -> "$device changed the password for $title"61 SyncAlertType.REMOTE_DELETE -> "$device deleted $title"62 SyncAlertType.DEVICE_REVOKED ->63 "$device revoked a paired device"64 SyncAlertType.DEVICE_KEY_ROTATED ->65 "$device's sync key changed - if you didn't reinstall Peach there, rotate your sync identity"66 }67}6869internal data class SyncSecurityNotificationContent(70 val title: String,71 val lines: List<String>,72) {73 val summary: String74 get() = if (lines.size == 1) lines.single() else "${lines.size} device security updates"75}7677internal fun syncSecurityNotificationContent(78 alerts: List<SyncAlert>,79): SyncSecurityNotificationContent? {80 val lines = alerts.filter(::isDeviceTrustAlert).map(::syncAlertMessage)81 if (lines.isEmpty()) return null82 return SyncSecurityNotificationContent(83 title = "Peach Sync security",84 lines = lines,85 )86}