SyncAlert.kt

2.9 KB · raw

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    // S5: name the device from LOCAL state. `incoming` is a third-party45    // issuer's unverified claim about someone else, so preferring it would let46    // an attacker make its own key rotation read as a rotation on a device the47    // user trusts more. Fall back to the manifest only when nothing is stored.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}