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 java.util.concurrent.locks.ReentrantLock13import javax.inject.Inject14import javax.inject.Singleton15import kotlin.concurrent.withLock1617private const val MAX_REVOCATION_RECORDS = 500181920212223242526272829303132333435363738394041@Singleton42class PairedDeviceStore @Inject constructor(43 @ApplicationContext context: Context,44) : PairedDeviceRegistry {45 private val masterKey = MasterKey.Builder(context)46 .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)47 .build()4849 private val prefs = EncryptedSharedPreferences.create(50 context,51 PREFS_FILE,52 masterKey,53 EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,54 EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,55 )5657 private val listSerializer = ListSerializer(PairedDevice.serializer())58 private val revocationSerializer = ListSerializer(DeviceRevocation.serializer())59 private val mutationGate = RegistryMutationGate()6061 62 @Volatile private var knownLocalDeviceId: String? = null6364 override fun getAll(): List<PairedDevice> {65 val raw = prefs.getString(KEY_DEVICES, null) ?: return emptyList()66 return try {67 json.decodeFromString(listSerializer, raw)68 } catch (e: Exception) {69 throw TrustDataCorruptedException(KEY_DEVICES, e.message ?: "decode_failed")70 }71 }7273 override fun get(id: String): PairedDevice? = getAll().firstOrNull { it.deviceId == id }7475 override fun getByPubkey(pubkeyHex: String): PairedDevice? =76 getAll().firstOrNull {77 it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true78 }7980 fun hasIdentityCollision(device: PairedDevice): Boolean =81 hasAdoptionIdentityConflict(device, getAll(), listRevocations())8283 84 fun upsertAndClearRevocation(device: PairedDevice) {85 mutationGate.withLock {86 val currentDevices = getAll()87 val existing = currentDevices.firstOrNull { it.deviceId == device.deviceId }88 val bound = bindImmutableIdentity(existing, device)89 val nextDevice = bound.copy(90 deviceName = bound.deviceName?.let(::capDeviceName),91 localAlias = existing?.localAlias,92 )93 val nextDevices = currentDevices.filterNot { it.deviceId == device.deviceId } + nextDevice94 val currentRevocations = listRevocations()95 val nextRevocations = currentRevocations.filterNot { it.deviceId == device.deviceId }96 val nextPrefs = RegistryPrefsState(97 devices = json.encodeToString(listSerializer, nextDevices),98 revocations = json.encodeToString(revocationSerializer, nextRevocations),99 )100 check(commitWithRollback(readPrefsState(), nextPrefs, ::writePrefsState)) {101 "paired_device_persist_failed"102 }103 }104 }105106 107 override fun upsert(device: PairedDevice) {108 mutationGate.withLock {109 val currentDevices = getAll()110 val existing = currentDevices.firstOrNull { it.deviceId == device.deviceId }111 val bound = bindImmutableIdentity(existing, device)112 113 114 val nextDevice = bound.copy(115 deviceName = bound.deviceName?.let(::capDeviceName),116 localAlias = existing?.localAlias,117 )118 val next = currentDevices.filterNot { it.deviceId == device.deviceId } + nextDevice119 persist(next)120 }121 }122123 124125126127128129130 override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean {131 return mutationGate.withLock {132 val priorPrefs = readPrefsState()133 val currentDevices = getAll()134 val currentById = currentDevices.associateBy { it.deviceId }135 val currentRevocations = listRevocations()136 val nextRevocations = mergeRevocationRecords(currentRevocations, batch.incomingRevocations).records137 val removed = batch.removals138 val nextDevices = currentDevices139 .filterNot { removed.contains(it.deviceId) }140 .toMutableList()141 batch.upserts.forEach { incoming ->142 val existing = currentById[incoming.deviceId]143 ?: nextDevices.firstOrNull { it.deviceId == incoming.deviceId }144 require(!nextDevices.any { candidate ->145 candidate.deviceId != incoming.deviceId &&146 ((candidate.longTermPublicKeyHex != null && incoming.longTermPublicKeyHex != null && CryptoCompare.hex(candidate.longTermPublicKeyHex, incoming.longTermPublicKeyHex)) ||147 (candidate.signingPublicKeyHex != null && incoming.signingPublicKeyHex != null && CryptoCompare.hex(candidate.signingPublicKeyHex, incoming.signingPublicKeyHex)))148 }) { "device_identity_collision" }149 val bound = bindImmutableIdentity(existing, incoming)150 val alias = nextDevices.firstOrNull { it.deviceId == incoming.deviceId }?.localAlias151 val device = bound.copy(152 deviceName = bound.deviceName?.let(::capDeviceName),153 localAlias = alias,154 )155 nextDevices.removeAll { it.deviceId == device.deviceId }156 nextDevices += device157 }158159 val nextPrefs = RegistryPrefsState(160 devices = json.encodeToString(listSerializer, nextDevices),161 revocations = json.encodeToString(revocationSerializer, nextRevocations),162 )163 164 165 166 commitWithRollback(priorPrefs, nextPrefs, ::writePrefsState)167 }168 }169170 fun rename(deviceId: String, name: String) {171 mutationGate.withLock {172 persist(withLocalAlias(getAll(), deviceId, name))173 }174 }175176 override fun remove(deviceId: String) {177 mutationGate.withLock {178 persist(getAll().filterNot { it.deviceId == deviceId })179 }180 }181182183184 185186 override fun listRevocations(): List<DeviceRevocation> {187 val raw = prefs.getString(KEY_REVOCATIONS, null) ?: return emptyList()188 return try {189 json.decodeFromString(revocationSerializer, raw)190 } catch (e: Exception) {191 throw TrustDataCorruptedException(KEY_REVOCATIONS, e.message ?: "decode_failed")192 }193 }194195 196197198199200201 fun revoke(202 deviceId: String,203 revokedByDeviceId: String,204 nowMs: Long = System.currentTimeMillis(),205 ): DeviceRevocation? = mutationGate.withLock {206 knownLocalDeviceId = revokedByDeviceId207 val currentDevices = getAll()208 val currentRevocations = listRevocations()209 val mutation = planDeviceRevocation(210 devices = currentDevices,211 revocations = currentRevocations,212 deviceId = deviceId,213 revokedByDeviceId = revokedByDeviceId,214 nowMs = nowMs,215 protectedIssuerIds = protectedRevocationIssuers(),216 )217 218 219 220 221 val nextPrefs = RegistryPrefsState(222 devices = json.encodeToString(listSerializer, mutation.devices),223 revocations = json.encodeToString(revocationSerializer, mutation.revocations),224 )225 check(commitWithRollback(readPrefsState(), nextPrefs, ::writePrefsState)) {226 "paired_device_persist_failed"227 }228 mutation.added229 }230231 232233234235236 override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {237 return mutationGate.withLock {238 val existing = listRevocations()239 val merged = mergeRevocationRecords(existing, incoming, protectedRevocationIssuers())240 if (merged.records != existing) persistRevocations(merged.records)241 merged.admitted.map { it.deviceId }242 }243 }244245 246247248249250251252253254255 private fun protectedRevocationIssuers(): Set<String> = buildSet {256 knownLocalDeviceId?.let(::add)257 getAll().filter { it.trustSource == "direct" }.forEach { add(it.deviceId) }258 }259260 261262263264265 override fun clearRevocation(deviceId: String) {266 mutationGate.withLock {267 val existing = listRevocations()268 val filtered = existing.filterNot { it.deviceId == deviceId }269 if (filtered.size != existing.size) persistRevocations(filtered)270 }271 }272273 fun clear() {274 mutationGate.withLock {275 val edit = prefs.edit()276 .remove(KEY_DEVICES)277 .remove(KEY_REVOCATIONS)278 279 280 281 for (key in prefs.all.keys.filter { it.startsWith(KEY_ISSUER_KEYS_PREFIX) }) {282 edit.remove(key)283 }284 edit.commit()285 }286 }287288 289 internal fun snapshot(): Map<String, Any?> = prefs.all290291 292293294295 fun updateLastSeen(id: String, ip: String? = null) {296 mutationGate.withLock {297 val current = get(id) ?: return@withLock298 if (ip == null) return@withLock299 upsert(current.copy(lastKnownIp = ip))300 }301 }302303 fun updateLastSync(id: String, at: Long = System.currentTimeMillis()) {304 mutationGate.withLock {305 val current = get(id) ?: return@withLock306 upsert(current.copy(lastSyncedAt = at))307 }308 }309310 311312313314315316 fun propagateRelaySetting(mode: SyncMode, storedRelayUrl: String) {317 mutationGate.withLock {318 val current = getAll()319 val next = withRelaySetting(current, mode, storedRelayUrl)320 if (next != current) persist(next)321 }322 }323324 325326327328329 fun observeAll(): Flow<List<PairedDevice>> = callbackFlow {330 trySend(getAll())331 val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->332 if (key == KEY_DEVICES || key == null) trySend(getAll())333 }334 prefs.registerOnSharedPreferenceChangeListener(listener)335 awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }336 }337338 override fun issuerSigningKeys(issuerDeviceId: String): Set<String> =339 prefs.getStringSet("$KEY_ISSUER_KEYS_PREFIX$issuerDeviceId", emptySet())340 .orEmpty().map(String::lowercase).toSet()341342 343 fun hasIssuerSigningKeys(): Boolean = prefs.all.keys.any { it.startsWith(KEY_ISSUER_KEYS_PREFIX) }344345 override fun appendIssuerSigningKey(issuerDeviceId: String, signingKeyHex: String): Boolean {346 val key = signingKeyHex.lowercase()347 if (issuerDeviceId.isBlank() || key.length != 64) return false348 synchronized(mutationGate) {349 val existing = issuerSigningKeys(issuerDeviceId)350 if (key in existing) return true351 return prefs.edit()352 .putStringSet("$KEY_ISSUER_KEYS_PREFIX$issuerDeviceId", existing + key)353 .commit()354 }355 }356357 358359360361362 private fun persistRevocations(records: List<DeviceRevocation>) {363 val prior = readPrefsState()364 val next = prior.copy(365 revocations = json.encodeToString(revocationSerializer, records),366 )367 check(commitWithRollback(prior, next, ::writePrefsState)) {368 "paired_device_persist_failed"369 }370 }371372 private fun persist(devices: List<PairedDevice>) {373 prefs.edit()374 .putString(KEY_DEVICES, json.encodeToString(listSerializer, devices))375 .apply()376 }377378 private data class RegistryPrefsState(379 val devices: String?,380 val revocations: String?,381 )382383 private fun readPrefsState(): RegistryPrefsState = RegistryPrefsState(384 devices = prefs.getString(KEY_DEVICES, null),385 revocations = prefs.getString(KEY_REVOCATIONS, null),386 )387388 private fun writePrefsState(state: RegistryPrefsState): Boolean {389 val edit = prefs.edit()390 if (state.devices == null) edit.remove(KEY_DEVICES) else edit.putString(KEY_DEVICES, state.devices)391 if (state.revocations == null) edit.remove(KEY_REVOCATIONS) else edit.putString(KEY_REVOCATIONS, state.revocations)392 return edit.commit()393 }394395 private companion object {396 const val PREFS_FILE = "peach_paired_devices"397 const val KEY_DEVICES = "paired_devices"398 const val KEY_REVOCATIONS = "device_revocations"399 const val KEY_ISSUER_KEYS_PREFIX = "issuer_signing_keys:"400 const val MAX_REVOCATIONS = MAX_REVOCATION_RECORDS401 val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }402 }403}404405406407408409410411class TrustDataCorruptedException(key: String, detail: String) :412 RuntimeException("trust_data_corrupted:$key:$detail")413414internal fun bindImmutableIdentity(existing: PairedDevice?, incoming: PairedDevice): PairedDevice {415 if (existing == null) return incoming416417 val longTermChanged = existing.longTermPublicKeyHex != null &&418 incoming.longTermPublicKeyHex != null &&419 !CryptoCompare.hex(existing.longTermPublicKeyHex, incoming.longTermPublicKeyHex)420 val signingChanged = existing.signingPublicKeyHex != null &&421 incoming.signingPublicKeyHex != null &&422 !CryptoCompare.hex(existing.signingPublicKeyHex, incoming.signingPublicKeyHex)423 require(!longTermChanged && !signingChanged) { "device_identity_immutable" }424425 return incoming.copy(426 longTermPublicKeyHex = incoming.longTermPublicKeyHex ?: existing.longTermPublicKeyHex,427 signingPublicKeyHex = incoming.signingPublicKeyHex ?: existing.signingPublicKeyHex,428 )429}430431432internal class RegistryMutationGate {433 private val lock = ReentrantLock()434435 fun <T> withLock(block: () -> T): T = lock.withLock(block)436}437438internal fun withLocalAlias(439 devices: List<PairedDevice>,440 deviceId: String,441 name: String,442): List<PairedDevice> {443 val alias = name.trim()444 require(alias.isNotEmpty()) { "device name must not be empty" }445 require(alias.length <= MAX_DEVICE_NAME_CHARS) { "device name must be 64 chars or fewer" }446 require(devices.any { it.deviceId == deviceId }) { "device_not_found" }447 return devices.map { if (it.deviceId == deviceId) it.copy(localAlias = alias) else it }448}449450internal data class DeviceRevocationMutation(451 val devices: List<PairedDevice>,452 val revocations: List<DeviceRevocation>,453 val added: DeviceRevocation?,454)455456457internal fun hasAdoptionIdentityConflict(458 incoming: PairedDevice,459 devices: List<PairedDevice>,460 revocations: List<DeviceRevocation>,461): Boolean {462 if (devices.any { it.deviceId == incoming.deviceId }) return true463 if (devices.any {464 it.longTermPublicKeyHex?.let { key -> incoming.longTermPublicKeyHex?.let { candidate -> CryptoCompare.hex(key, candidate) } } == true ||465 it.signingPublicKeyHex?.let { key -> incoming.signingPublicKeyHex?.let { candidate -> CryptoCompare.hex(key, candidate) } } == true466 }) return true467 return revocations.any { revoked ->468 revoked.deviceId == incoming.deviceId ||469 incoming.longTermPublicKeyHex?.let { CryptoCompare.hex(revoked.longTermPublicKeyHex, it) } == true470 }471}472473474475476477478479480internal fun planDeviceRevocation(481 devices: List<PairedDevice>,482 revocations: List<DeviceRevocation>,483 deviceId: String,484 revokedByDeviceId: String,485 nowMs: Long,486 protectedIssuerIds: Set<String> = emptySet(),487): DeviceRevocationMutation {488 val remaining = devices.filterNot { it.deviceId == deviceId }489 if (revocations.any { it.deviceId == deviceId }) {490 return DeviceRevocationMutation(remaining, revocations, null)491 }492 val device = devices.firstOrNull { it.deviceId == deviceId }493 val record = DeviceRevocation(494 deviceId = deviceId,495 longTermPublicKeyHex = device?.longTermPublicKeyHex ?: "0".repeat(64),496 revokedAt = nowMs,497 revokedByDeviceId = revokedByDeviceId,498 )499 return DeviceRevocationMutation(500 remaining,501 capRevocationRecords(revocations + record, protectedIssuerIds).records,502 record,503 )504}505internal data class RevocationMergeResult(506 val records: List<DeviceRevocation>,507 val admitted: List<DeviceRevocation>,508 val outcome: RevocationMergeOutcome = RevocationMergeOutcome.MERGED,509)510511internal enum class RevocationMergeOutcome {512 MERGED,513 OVERFLOW_PROTECTED_LEDGER,514}515516internal data class RevocationCapResult(517 val records: List<DeviceRevocation>,518 val outcome: RevocationMergeOutcome,519)520521internal fun mergeRevocationRecords(522 existing: List<DeviceRevocation>,523 incoming: List<DeviceRevocation>,524 protectedIssuerIds: Set<String> = emptySet(),525): RevocationMergeResult {526 val known = existing.map { it.deviceId }.toSet()527 val fresh = incoming.filterNot { known.contains(it.deviceId) }528 val capped = capRevocationRecords(existing + fresh, protectedIssuerIds)529 if (capped.outcome == RevocationMergeOutcome.OVERFLOW_PROTECTED_LEDGER) {530 return RevocationMergeResult(existing, emptyList(), capped.outcome)531 }532 val admitted = fresh.filter { candidate -> capped.records.any { it.deviceId == candidate.deviceId } }533 return RevocationMergeResult(capped.records, admitted, capped.outcome)534}535536537538539540541542internal fun capRevocationRecords(543 records: List<DeviceRevocation>,544 protectedIssuerIds: Set<String> = emptySet(),545): RevocationCapResult {546 if (records.size <= MAX_REVOCATION_RECORDS) {547 return RevocationCapResult(records, RevocationMergeOutcome.MERGED)548 }549 val candidates = records.indices.filter { records[it].revokedByDeviceId !in protectedIssuerIds }550 if (candidates.size < records.size - MAX_REVOCATION_RECORDS) {551 return RevocationCapResult(records, RevocationMergeOutcome.OVERFLOW_PROTECTED_LEDGER)552 }553 val dropped = candidates.take(records.size - MAX_REVOCATION_RECORDS).toSet()554 return RevocationCapResult(555 records.filterIndexed { index, _ -> index !in dropped },556 RevocationMergeOutcome.MERGED,557 )558}559560561562563564565566internal fun withRelaySetting(567 devices: List<PairedDevice>,568 mode: SyncMode,569 storedRelayUrl: String,570): List<PairedDevice> = when (val decision = relayInForce(mode, storedRelayUrl)) {571 is RelayDecision.Dial -> devices.map { it.copy(relayUrl = decision.relayUrl) }572 is RelayDecision.Refuse -> devices573}