package com.peachpasswords.sync import kotlinx.serialization.Serializable @Serializable enum class SyncAlertType { REMOTE_PASSWORD_CHANGE, REMOTE_DELETE, DEVICE_KEY_ROTATED, DEVICE_REVOKED, } @Serializable data class SyncAlert( val type: SyncAlertType, val entryId: String? = null, val entryTitle: String? = null, val deviceName: String? = null, val timestamp: Long, ) interface SyncAlertRecorder { fun record(alerts: List) } internal fun isDeviceTrustAlert(alert: SyncAlert): Boolean = alert.type == SyncAlertType.DEVICE_KEY_ROTATED || alert.type == SyncAlertType.DEVICE_REVOKED internal fun deviceKeyRotationAlert( existing: PairedDevice, incoming: ManifestDeviceEntry, nowMs: Long, ): SyncAlert? { val nextLongTerm = incoming.longTermPublicKeyHex.lowercase() val nextSigning = incoming.signingPublicKeyHex.lowercase() val longTermChanged = existing.longTermPublicKeyHex?.let { !CryptoCompare.hex(it, nextLongTerm) } == true val signingChanged = existing.signingPublicKeyHex?.let { !CryptoCompare.hex(it, nextSigning) } == true if (!longTermChanged && !signingChanged) return null // S5: name the device from LOCAL state. `incoming` is a third-party // issuer's unverified claim about someone else, so preferring it would let // an attacker make its own key rotation read as a rotation on a device the // user trusts more. Fall back to the manifest only when nothing is stored. return SyncAlert( type = SyncAlertType.DEVICE_KEY_ROTATED, deviceName = existing.deviceName?.takeIf { it.isNotBlank() } ?: incoming.deviceName.ifBlank { "paired device" }, timestamp = nowMs, ) } fun syncAlertMessage(alert: SyncAlert): String { val device = alert.deviceName?.takeIf { it.isNotBlank() } ?: "paired device" val title = alert.entryTitle?.takeIf { it.isNotBlank() } ?: "an entry" return when (alert.type) { SyncAlertType.REMOTE_PASSWORD_CHANGE -> "$device changed the password for $title" SyncAlertType.REMOTE_DELETE -> "$device deleted $title" SyncAlertType.DEVICE_REVOKED -> "$device revoked a paired device" SyncAlertType.DEVICE_KEY_ROTATED -> "$device's sync key changed - if you didn't reinstall Peach there, rotate your sync identity" } } internal data class SyncSecurityNotificationContent( val title: String, val lines: List, ) { val summary: String get() = if (lines.size == 1) lines.single() else "${lines.size} device security updates" } internal fun syncSecurityNotificationContent( alerts: List, ): SyncSecurityNotificationContent? { val lines = alerts.filter(::isDeviceTrustAlert).map(::syncAlertMessage) if (lines.isEmpty()) return null return SyncSecurityNotificationContent( title = "Peach Sync security", lines = lines, ) }