SyncAlert.kt

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