SyncAlert.kt

3.7 KB · raw

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    // S5: name the device from LOCAL state. `incoming` is a third-party88    // issuer's unverified claim about someone else, so preferring it would let89    // an attacker make its own key rotation read as a rotation on a device the90    // user trusts more. Fall back to the manifest only when nothing is stored.91    return SyncAlert(92        type = SyncAlertType.DEVICE_KEY_ROTATED,93        deviceName = existing.deviceName?.takeIf { it.isNotBlank() }94            ?: incoming.deviceName.ifBlank { "paired device" },95        timestamp = nowMs,96    )97}9899fun syncAlertMessage(alert: SyncAlert): String {100    val device = alert.deviceName?.takeIf { it.isNotBlank() } ?: "paired device"101    val title = alert.entryTitle?.takeIf { it.isNotBlank() } ?: "an entry"102    return when (alert.type) {103        SyncAlertType.REMOTE_PASSWORD_CHANGE -> "$device changed the password for $title"104        SyncAlertType.REMOTE_DELETE -> "$device deleted $title"105        SyncAlertType.DEVICE_KEY_ROTATED ->106            "$device's sync key changed - if you didn't reinstall Peach there, rotate your sync identity"107    }108}