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.Singleton141516171819202122232425262728293031323334353637@Singleton38class PairedDeviceStore @Inject constructor(39 @ApplicationContext context: Context,40) : PairedDeviceRegistry {41 private val masterKey = MasterKey.Builder(context)42 .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)43 .build()4445 private val prefs = EncryptedSharedPreferences.create(46 context,47 PREFS_FILE,48 masterKey,49 EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,50 EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,51 )5253 private val listSerializer = ListSerializer(PairedDevice.serializer())54 private val revocationSerializer = ListSerializer(DeviceRevocation.serializer())5556 override fun getAll(): List<PairedDevice> {57 val raw = prefs.getString(KEY_DEVICES, null) ?: return emptyList()58 return runCatching { json.decodeFromString(listSerializer, raw) }.getOrDefault(emptyList())59 }6061 override fun get(id: String): PairedDevice? = getAll().firstOrNull { it.deviceId == id }6263 override fun getByPubkey(pubkeyHex: String): PairedDevice? =64 getAll().firstOrNull {65 it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true66 }6768 69 override fun upsert(device: PairedDevice) {70 71 72 val device = device.copy(73 deviceName = device.deviceName?.let(::capDeviceName),74 localAlias = get(device.deviceId)?.localAlias,75 )76 val next = getAll().filterNot { it.deviceId == device.deviceId } + device77 persist(next)78 }7980 fun rename(deviceId: String, name: String) {81 persist(withLocalAlias(getAll(), deviceId, name))82 }8384 override fun remove(deviceId: String) {85 persist(getAll().filterNot { it.deviceId == deviceId })86 }8788 89909192939495 override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean {96 val priorPrefs = readPrefsState()97 val currentDevices = getAll()98 val currentRevocations = listRevocations()99 val knownRevokedIds = currentRevocations.map { it.deviceId }.toSet()100 val freshRevocations = batch.incomingRevocations.filterNot { knownRevokedIds.contains(it.deviceId) }101 val nextRevocations = (currentRevocations + freshRevocations).takeLast(MAX_REVOCATIONS)102 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 known = existing.map { it.deviceId }.toSet()170 val fresh = incoming.filterNot { known.contains(it.deviceId) }171 if (fresh.isEmpty()) return emptyList()172 persistRevocations((existing + fresh).takeLast(MAX_REVOCATIONS))173 return fresh.map { it.deviceId }174 }175176 177178179180181 override fun clearRevocation(deviceId: String) {182 val existing = listRevocations()183 val filtered = existing.filterNot { it.deviceId == deviceId }184 if (filtered.size != existing.size) persistRevocations(filtered)185 }186187 fun clear() {188 prefs.edit()189 .remove(KEY_DEVICES)190 .remove(KEY_REVOCATIONS)191 .apply()192 }193194 195196197198 fun updateLastSeen(id: String, ip: String? = null) {199 val current = get(id) ?: return200 if (ip == null) return201 upsert(current.copy(lastKnownIp = ip))202 }203204 fun updateLastSync(id: String, at: Long = System.currentTimeMillis()) {205 val current = get(id) ?: return206 upsert(current.copy(lastSyncedAt = at))207 }208209 210211212213214215 fun propagateRelaySetting(mode: SyncMode, storedRelayUrl: String) {216 val current = getAll()217 val next = withRelaySetting(current, mode, storedRelayUrl)218 if (next != current) persist(next)219 }220221 222223224225226 fun observeAll(): Flow<List<PairedDevice>> = callbackFlow {227 trySend(getAll())228 val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->229 if (key == KEY_DEVICES || key == null) trySend(getAll())230 }231 prefs.registerOnSharedPreferenceChangeListener(listener)232 awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }233 }234235 private fun persistRevocations(records: List<DeviceRevocation>) {236 prefs.edit()237 .putString(KEY_REVOCATIONS, json.encodeToString(revocationSerializer, records))238 .apply()239 }240241 private fun persist(devices: List<PairedDevice>) {242 prefs.edit()243 .putString(KEY_DEVICES, json.encodeToString(listSerializer, devices))244 .apply()245 }246247 private data class RegistryPrefsState(248 val devices: String?,249 val revocations: String?,250 )251252 private fun readPrefsState(): RegistryPrefsState = RegistryPrefsState(253 devices = prefs.getString(KEY_DEVICES, null),254 revocations = prefs.getString(KEY_REVOCATIONS, null),255 )256257 private fun writePrefsState(state: RegistryPrefsState): Boolean {258 val edit = prefs.edit()259 if (state.devices == null) edit.remove(KEY_DEVICES) else edit.putString(KEY_DEVICES, state.devices)260 if (state.revocations == null) edit.remove(KEY_REVOCATIONS) else edit.putString(KEY_REVOCATIONS, state.revocations)261 return edit.commit()262 }263264 private companion object {265 const val PREFS_FILE = "peach_paired_devices"266 const val KEY_DEVICES = "paired_devices"267 const val KEY_REVOCATIONS = "device_revocations"268 269 const val MAX_REVOCATIONS = 500270 val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }271 }272}273274internal fun withLocalAlias(275 devices: List<PairedDevice>,276 deviceId: String,277 name: String,278): List<PairedDevice> {279 val alias = name.trim()280 require(alias.isNotEmpty()) { "device name must not be empty" }281 require(alias.length <= MAX_DEVICE_NAME_CHARS) { "device name must be 64 chars or fewer" }282 require(devices.any { it.deviceId == deviceId }) { "device_not_found" }283 return devices.map { if (it.deviceId == deviceId) it.copy(localAlias = alias) else it }284}285286internal data class DeviceRevocationMutation(287 val devices: List<PairedDevice>,288 val revocations: List<DeviceRevocation>,289 val added: DeviceRevocation?,290)291292293294295296297298299internal fun planDeviceRevocation(300 devices: List<PairedDevice>,301 revocations: List<DeviceRevocation>,302 deviceId: String,303 revokedByDeviceId: String,304 nowMs: Long,305): DeviceRevocationMutation {306 val remaining = devices.filterNot { it.deviceId == deviceId }307 if (revocations.any { it.deviceId == deviceId }) {308 return DeviceRevocationMutation(remaining, revocations, null)309 }310 val device = devices.firstOrNull { it.deviceId == deviceId }311 val record = DeviceRevocation(312 deviceId = deviceId,313 longTermPublicKeyHex = device?.longTermPublicKeyHex ?: "0".repeat(64),314 revokedAt = nowMs,315 revokedByDeviceId = revokedByDeviceId,316 )317 return DeviceRevocationMutation(remaining, revocations + record, record)318}319320321322323324325326internal fun withRelaySetting(327 devices: List<PairedDevice>,328 mode: SyncMode,329 storedRelayUrl: String,330): List<PairedDevice> = when (val decision = relayInForce(mode, storedRelayUrl)) {331 is RelayDecision.Dial -> devices.map { it.copy(relayUrl = decision.relayUrl) }332 is RelayDecision.Refuse -> devices333}