SyncAlert.kt

2.5 KB · raw

1package com.peachpasswords.sync23import kotlinx.serialization.Serializable45private const val MAX_SYNC_ALERTS = 20067@Serializable8enum class SyncAlertType {9    REMOTE_PASSWORD_CHANGE,10    REMOTE_DELETE,11    DEVICE_KEY_ROTATED,12    DEVICE_REVOKED,13}1415@Serializable16data class SyncAlert(17    val type: SyncAlertType,18    val entryId: String? = null,19    val entryTitle: String? = null,20    val deviceName: String? = null,21    val timestamp: Long,22)2324interface SyncAlertRecorder {25    fun record(alerts: List<SyncAlert>)26}2728internal fun boundedSyncAlerts(existing: List<SyncAlert>, incoming: List<SyncAlert>): List<SyncAlert> =29    (existing + incoming).takeLast(MAX_SYNC_ALERTS)3031internal fun isDeviceTrustAlert(alert: SyncAlert): Boolean =32    alert.type == SyncAlertType.DEVICE_KEY_ROTATED ||33        alert.type == SyncAlertType.DEVICE_REVOKED3435internal fun deviceKeyRotationAlert(36    existing: PairedDevice,37    incoming: ManifestDeviceEntry,38    nowMs: Long,39): SyncAlert? {40    val nextLongTerm = incoming.longTermPublicKeyHex.lowercase()41    val nextSigning = incoming.signingPublicKeyHex.lowercase()42    val longTermChanged = existing.longTermPublicKeyHex?.let {43        !CryptoCompare.hex(it, nextLongTerm)44    } == true45    val signingChanged = existing.signingPublicKeyHex?.let {46        !CryptoCompare.hex(it, nextSigning)47    } == true48    if (!longTermChanged && !signingChanged) return null49    // S5: name the device from LOCAL state. `incoming` is a third-party50    // issuer's unverified claim about someone else, so preferring it would let51    // an attacker make its own key rotation read as a rotation on a device the52    // user trusts more. Fall back to the manifest only when nothing is stored.53    return SyncAlert(54        type = SyncAlertType.DEVICE_KEY_ROTATED,55        deviceName = existing.deviceName?.takeIf { it.isNotBlank() }56            ?: incoming.deviceName.ifBlank { "paired device" },57        timestamp = nowMs,58    )59}6061fun syncAlertMessage(alert: SyncAlert): String {62    val device = alert.deviceName?.takeIf { it.isNotBlank() } ?: "paired device"63    val title = alert.entryTitle?.takeIf { it.isNotBlank() } ?: "an entry"64    return when (alert.type) {65        SyncAlertType.REMOTE_PASSWORD_CHANGE -> "$device changed the password for $title"66        SyncAlertType.REMOTE_DELETE -> "$device deleted $title"67        SyncAlertType.DEVICE_REVOKED ->68            "$device revoked a paired device"69        SyncAlertType.DEVICE_KEY_ROTATED ->70            "$device's sync key changed - if you didn't reinstall Peach there, rotate your sync identity"71    }72}