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 knownRevokedIds = currentRevocations.map { it.deviceId }.toSet()102 val freshRevocations = batch.incomingRevocations.filterNot { knownRevokedIds.contains(it.deviceId) }103 val admittedRevocations = freshRevocations.take((MAX_REVOCATIONS - currentRevocations.size).coerceAtLeast(0))104 val nextRevocations = currentRevocations + admittedRevocations105 val removed = batch.removals106 val nextDevices = currentDevices107 .filterNot { removed.contains(it.deviceId) }108 .toMutableList()109 batch.upserts.forEach { device ->110 val alias = nextDevices.firstOrNull { it.deviceId == device.deviceId }?.localAlias111 val device = device.copy(112 deviceName = device.deviceName?.let(::capDeviceName),113 localAlias = alias,114 )115 nextDevices.removeAll { it.deviceId == device.deviceId }116 nextDevices += device117 }118119 val nextPrefs = RegistryPrefsState(120 devices = json.encodeToString(listSerializer, nextDevices),121 revocations = json.encodeToString(revocationSerializer, nextRevocations),122 )123 124 125 126 return commitWithRollback(priorPrefs, nextPrefs, ::writePrefsState)127 }128129 130131 override fun listRevocations(): List<DeviceRevocation> {132 val raw = prefs.getString(KEY_REVOCATIONS, null) ?: return emptyList()133 return runCatching { json.decodeFromString(revocationSerializer, raw) }134 .getOrDefault(emptyList())135 }136137 138139140141142143 fun revoke(144 deviceId: String,145 revokedByDeviceId: String,146 nowMs: Long = System.currentTimeMillis(),147 ): DeviceRevocation? {148 val currentDevices = getAll()149 val currentRevocations = listRevocations()150 val mutation = planDeviceRevocation(151 devices = currentDevices,152 revocations = currentRevocations,153 deviceId = deviceId,154 revokedByDeviceId = revokedByDeviceId,155 nowMs = nowMs,156 )157 if (mutation.revocations !== currentRevocations) {158 persistRevocations(mutation.revocations)159 }160 if (mutation.devices != currentDevices) {161 persist(mutation.devices)162 }163 return mutation.added164 }165166 167168169170 override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {171 val existing = listRevocations()172 val known = existing.map { it.deviceId }.toSet()173 val fresh = incoming.filterNot { known.contains(it.deviceId) }174 if (fresh.isEmpty()) return emptyList()175 val admitted = fresh.take((MAX_REVOCATIONS - existing.size).coerceAtLeast(0))176 if (admitted.isNotEmpty()) persistRevocations(existing + admitted)177 return admitted.map { it.deviceId }178 }179180 181182183184185 override fun clearRevocation(deviceId: String) {186 val existing = listRevocations()187 val filtered = existing.filterNot { it.deviceId == deviceId }188 if (filtered.size != existing.size) persistRevocations(filtered)189 }190191 fun clear() {192 prefs.edit()193 .remove(KEY_DEVICES)194 .remove(KEY_REVOCATIONS)195 .apply()196 }197198 199200201202 fun updateLastSeen(id: String, ip: String? = null) {203 val current = get(id) ?: return204 if (ip == null) return205 upsert(current.copy(lastKnownIp = ip))206 }207208 fun updateLastSync(id: String, at: Long = System.currentTimeMillis()) {209 val current = get(id) ?: return210 upsert(current.copy(lastSyncedAt = at))211 }212213 214215216217218219 fun propagateRelaySetting(mode: SyncMode, storedRelayUrl: String) {220 val current = getAll()221 val next = withRelaySetting(current, mode, storedRelayUrl)222 if (next != current) persist(next)223 }224225 226227228229230 fun observeAll(): Flow<List<PairedDevice>> = callbackFlow {231 trySend(getAll())232 val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->233 if (key == KEY_DEVICES || key == null) trySend(getAll())234 }235 prefs.registerOnSharedPreferenceChangeListener(listener)236 awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }237 }238239 private fun persistRevocations(records: List<DeviceRevocation>) {240 prefs.edit()241 .putString(KEY_REVOCATIONS, json.encodeToString(revocationSerializer, records))242 .apply()243 }244245 private fun persist(devices: List<PairedDevice>) {246 prefs.edit()247 .putString(KEY_DEVICES, json.encodeToString(listSerializer, devices))248 .apply()249 }250251 private data class RegistryPrefsState(252 val devices: String?,253 val revocations: String?,254 )255256 private fun readPrefsState(): RegistryPrefsState = RegistryPrefsState(257 devices = prefs.getString(KEY_DEVICES, null),258 revocations = prefs.getString(KEY_REVOCATIONS, null),259 )260261 private fun writePrefsState(state: RegistryPrefsState): Boolean {262 val edit = prefs.edit()263 if (state.devices == null) edit.remove(KEY_DEVICES) else edit.putString(KEY_DEVICES, state.devices)264 if (state.revocations == null) edit.remove(KEY_REVOCATIONS) else edit.putString(KEY_REVOCATIONS, state.revocations)265 return edit.commit()266 }267268 private companion object {269 const val PREFS_FILE = "peach_paired_devices"270 const val KEY_DEVICES = "paired_devices"271 const val KEY_REVOCATIONS = "device_revocations"272 273 const val MAX_REVOCATIONS = MAX_REVOCATION_RECORDS274 val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }275 }276}277278internal fun withLocalAlias(279 devices: List<PairedDevice>,280 deviceId: String,281 name: String,282): List<PairedDevice> {283 val alias = name.trim()284 require(alias.isNotEmpty()) { "device name must not be empty" }285 require(alias.length <= MAX_DEVICE_NAME_CHARS) { "device name must be 64 chars or fewer" }286 require(devices.any { it.deviceId == deviceId }) { "device_not_found" }287 return devices.map { if (it.deviceId == deviceId) it.copy(localAlias = alias) else it }288}289290internal data class DeviceRevocationMutation(291 val devices: List<PairedDevice>,292 val revocations: List<DeviceRevocation>,293 val added: DeviceRevocation?,294)295296297298299300301302303internal fun planDeviceRevocation(304 devices: List<PairedDevice>,305 revocations: List<DeviceRevocation>,306 deviceId: String,307 revokedByDeviceId: String,308 nowMs: Long,309): DeviceRevocationMutation {310 val remaining = devices.filterNot { it.deviceId == deviceId }311 if (revocations.any { it.deviceId == deviceId }) {312 return DeviceRevocationMutation(remaining, revocations, null)313 }314 val device = devices.firstOrNull { it.deviceId == deviceId }315 val record = DeviceRevocation(316 deviceId = deviceId,317 longTermPublicKeyHex = device?.longTermPublicKeyHex ?: "0".repeat(64),318 revokedAt = nowMs,319 revokedByDeviceId = revokedByDeviceId,320 )321 val retained = if (revocations.size < MAX_REVOCATIONS) {322 revocations323 } else {324 val removable = revocations.indexOfFirst { it.revokedByDeviceId != revokedByDeviceId }325 if (removable >= 0) revocations.toMutableList().also { it.removeAt(removable) } else revocations326 }327 return DeviceRevocationMutation(remaining, (retained + record).takeLast(MAX_REVOCATIONS), record)328}329330331332333334335336internal fun withRelaySetting(337 devices: List<PairedDevice>,338 mode: SyncMode,339 storedRelayUrl: String,340): List<PairedDevice> = when (val decision = relayInForce(mode, storedRelayUrl)) {341 is RelayDecision.Dial -> devices.map { it.copy(relayUrl = decision.relayUrl) }342 is RelayDecision.Refuse -> devices343}