1package com.peachpasswords.sync23import android.content.Context4import androidx.security.crypto.EncryptedSharedPreferences5import androidx.security.crypto.MasterKey6import dagger.hilt.android.qualifiers.ApplicationContext7import kotlinx.coroutines.channels.awaitClose8import kotlinx.coroutines.flow.Flow9import kotlinx.coroutines.flow.callbackFlow10import kotlinx.serialization.builtins.ListSerializer11import kotlinx.serialization.json.Json12import javax.inject.Inject13import javax.inject.Singleton1415private const val MAX_REVOCATION_RECORDS = 500161718192021222324252627282930313233343536373839@Singleton40class PairedDeviceStore @Inject constructor(41 @ApplicationContext context: Context,42) : PairedDeviceRegistry {43 private val masterKey = MasterKey.Builder(context)44 .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)45 .build()4647 private val prefs = EncryptedSharedPreferences.create(48 context,49 PREFS_FILE,50 masterKey,51 EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,52 EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,53 )5455 private val listSerializer = ListSerializer(PairedDevice.serializer())56 private val revocationSerializer = ListSerializer(DeviceRevocation.serializer())5758 override fun getAll(): List<PairedDevice> {59 val raw = prefs.getString(KEY_DEVICES, null) ?: return emptyList()60 return runCatching { json.decodeFromString(listSerializer, raw) }.getOrDefault(emptyList())61 }6263 override fun get(id: String): PairedDevice? = getAll().firstOrNull { it.deviceId == id }6465 override fun getByPubkey(pubkeyHex: String): PairedDevice? =66 getAll().firstOrNull {67 it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true68 }6970 71 override fun upsert(device: PairedDevice) {72 73 74 val device = device.copy(75 deviceName = device.deviceName?.let(::capDeviceName),76 localAlias = get(device.deviceId)?.localAlias,77 )78 val next = getAll().filterNot { it.deviceId == device.deviceId } + device79 persist(next)80 }8182 fun rename(deviceId: String, name: String) {83 persist(withLocalAlias(getAll(), deviceId, name))84 }8586 override fun remove(deviceId: String) {87 persist(getAll().filterNot { it.deviceId == deviceId })88 }8990 91929394959697 override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean {98 val priorPrefs = readPrefsState()99 val currentDevices = getAll()100 val currentRevocations = listRevocations()101 val nextRevocations = mergeRevocationRecords(currentRevocations, batch.incomingRevocations).records102 val removed = batch.removals103 val nextDevices = currentDevices104 .filterNot { removed.contains(it.deviceId) }105 .toMutableList()106 batch.upserts.forEach { device ->107 val alias = nextDevices.firstOrNull { it.deviceId == device.deviceId }?.localAlias108 val device = device.copy(109 deviceName = device.deviceName?.let(::capDeviceName),110 localAlias = alias,111 )112 nextDevices.removeAll { it.deviceId == device.deviceId }113 nextDevices += device114 }115116 val nextPrefs = RegistryPrefsState(117 devices = json.encodeToString(listSerializer, nextDevices),118 revocations = json.encodeToString(revocationSerializer, nextRevocations),119 )120 121 122 123 return commitWithRollback(priorPrefs, nextPrefs, ::writePrefsState)124 }125126 127128 override fun listRevocations(): List<DeviceRevocation> {129 val raw = prefs.getString(KEY_REVOCATIONS, null) ?: return emptyList()130 return runCatching { json.decodeFromString(revocationSerializer, raw) }131 .getOrDefault(emptyList())132 }133134 135136137138139140 fun revoke(141 deviceId: String,142 revokedByDeviceId: String,143 nowMs: Long = System.currentTimeMillis(),144 ): DeviceRevocation? {145 val currentDevices = getAll()146 val currentRevocations = listRevocations()147 val mutation = planDeviceRevocation(148 devices = currentDevices,149 revocations = currentRevocations,150 deviceId = deviceId,151 revokedByDeviceId = revokedByDeviceId,152 nowMs = nowMs,153 )154 if (mutation.revocations !== currentRevocations) {155 persistRevocations(mutation.revocations)156 }157 if (mutation.devices != currentDevices) {158 persist(mutation.devices)159 }160 return mutation.added161 }162163 164165166167 override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {168 val existing = listRevocations()169 val merged = mergeRevocationRecords(existing, incoming)170 if (merged.records != existing) persistRevocations(merged.records)171 return merged.admitted.map { it.deviceId }172 }173174 175176177178179 override fun clearRevocation(deviceId: String) {180 val existing = listRevocations()181 val filtered = existing.filterNot { it.deviceId == deviceId }182 if (filtered.size != existing.size) persistRevocations(filtered)183 }184185 fun clear() {186 prefs.edit()187 .remove(KEY_DEVICES)188 .remove(KEY_REVOCATIONS)189 .apply()190 }191192 193194195196 fun updateLastSeen(id: String, ip: String? = null) {197 val current = get(id) ?: return198 if (ip == null) return199 upsert(current.copy(lastKnownIp = ip))200 }201202 fun updateLastSync(id: String, at: Long = System.currentTimeMillis()) {203 val current = get(id) ?: return204 upsert(current.copy(lastSyncedAt = at))205 }206207 208209210211212213 fun propagateRelaySetting(mode: SyncMode, storedRelayUrl: String) {214 val current = getAll()215 val next = withRelaySetting(current, mode, storedRelayUrl)216 if (next != current) persist(next)217 }218219 220221222223224 fun observeAll(): Flow<List<PairedDevice>> = callbackFlow {225 trySend(getAll())226 val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->227 if (key == KEY_DEVICES || key == null) trySend(getAll())228 }229 prefs.registerOnSharedPreferenceChangeListener(listener)230 awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }231 }232233 private fun persistRevocations(records: List<DeviceRevocation>) {234 prefs.edit()235 .putString(KEY_REVOCATIONS, json.encodeToString(revocationSerializer, records))236 .apply()237 }238239 private fun persist(devices: List<PairedDevice>) {240 prefs.edit()241 .putString(KEY_DEVICES, json.encodeToString(listSerializer, devices))242 .apply()243 }244245 private data class RegistryPrefsState(246 val devices: String?,247 val revocations: String?,248 )249250 private fun readPrefsState(): RegistryPrefsState = RegistryPrefsState(251 devices = prefs.getString(KEY_DEVICES, null),252 revocations = prefs.getString(KEY_REVOCATIONS, null),253 )254255 private fun writePrefsState(state: RegistryPrefsState): Boolean {256 val edit = prefs.edit()257 if (state.devices == null) edit.remove(KEY_DEVICES) else edit.putString(KEY_DEVICES, state.devices)258 if (state.revocations == null) edit.remove(KEY_REVOCATIONS) else edit.putString(KEY_REVOCATIONS, state.revocations)259 return edit.commit()260 }261262 private companion object {263 const val PREFS_FILE = "peach_paired_devices"264 const val KEY_DEVICES = "paired_devices"265 const val KEY_REVOCATIONS = "device_revocations"266 267 const val MAX_REVOCATIONS = MAX_REVOCATION_RECORDS268 val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }269 }270}271272internal fun withLocalAlias(273 devices: List<PairedDevice>,274 deviceId: String,275 name: String,276): List<PairedDevice> {277 val alias = name.trim()278 require(alias.isNotEmpty()) { "device name must not be empty" }279 require(alias.length <= MAX_DEVICE_NAME_CHARS) { "device name must be 64 chars or fewer" }280 require(devices.any { it.deviceId == deviceId }) { "device_not_found" }281 return devices.map { if (it.deviceId == deviceId) it.copy(localAlias = alias) else it }282}283284internal data class DeviceRevocationMutation(285 val devices: List<PairedDevice>,286 val revocations: List<DeviceRevocation>,287 val added: DeviceRevocation?,288)289290291292293294295296297internal fun planDeviceRevocation(298 devices: List<PairedDevice>,299 revocations: List<DeviceRevocation>,300 deviceId: String,301 revokedByDeviceId: String,302 nowMs: Long,303): DeviceRevocationMutation {304 val remaining = devices.filterNot { it.deviceId == deviceId }305 if (revocations.any { it.deviceId == deviceId }) {306 return DeviceRevocationMutation(remaining, revocations, null)307 }308 val device = devices.firstOrNull { it.deviceId == deviceId }309 val record = DeviceRevocation(310 deviceId = deviceId,311 longTermPublicKeyHex = device?.longTermPublicKeyHex ?: "0".repeat(64),312 revokedAt = nowMs,313 revokedByDeviceId = revokedByDeviceId,314 )315 val retained = if (revocations.size < MAX_REVOCATION_RECORDS) {316 revocations317 } else {318 val removable = revocations.indexOfFirst { it.revokedByDeviceId != revokedByDeviceId }319 if (removable >= 0) revocations.toMutableList().also { it.removeAt(removable) } else revocations320 }321 return DeviceRevocationMutation(322 remaining,323 (retained + record).takeLast(MAX_REVOCATION_RECORDS),324 record,325 )326}327328internal data class RevocationMergeResult(329 val records: List<DeviceRevocation>,330 val admitted: List<DeviceRevocation>,331)332333internal fun mergeRevocationRecords(334 existing: List<DeviceRevocation>,335 incoming: List<DeviceRevocation>,336): RevocationMergeResult {337 val known = existing.map { it.deviceId }.toSet()338 val fresh = incoming.filterNot { known.contains(it.deviceId) }339 val admitted = fresh.take((MAX_REVOCATION_RECORDS - existing.size).coerceAtLeast(0))340 return RevocationMergeResult(existing + admitted, admitted)341}342343344345346347348349internal fun withRelaySetting(350 devices: List<PairedDevice>,351 mode: SyncMode,352 storedRelayUrl: String,353): List<PairedDevice> = when (val decision = relayInForce(mode, storedRelayUrl)) {354 is RelayDecision.Dial -> devices.map { it.copy(relayUrl = decision.relayUrl) }355 is RelayDecision.Refuse -> devices356}