package com.peachpasswords.sync import android.content.Context import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton /** * Persistent registry of paired devices, encrypted at rest with * EncryptedSharedPreferences (same construction as DuressConfigStore / * LicenseRepository, v2.1.36). The whole list lives under a single key as a * kotlinx.serialization JSON array of the REAL [PairedDevice] (sync package). * * Reconciliation with the C7 brief: * - The brief invents a fresh `PairedDevice(id, name, publicKey: ByteArray, * lastSeen, trustStatus, lastSyncAt)`. That type does NOT exist in this * shape. The real [PairedDevice] (PairedDevice.kt) keys on `deviceId`, holds * the public key as `longTermPublicKeyHex` (hex, not ByteArray), and tracks * `pairedAt` / `lastSyncedAt` / `lastKnownIp`. This store reuses it verbatim; * Base64 publicKey storage is therefore unnecessary — the hex field is the * canonical wire form already produced by PairingFrames.frameToPairedDevice. * - The brief's `TrustStatus` enum does not exist either. The real model uses a * nullable `trustSource: String?` (e.g. "direct", "manifest"). Trust state is * expressed there. See C7-INTEGRATION.md for the trust-status delta. * - `updateLastSync` here maps to the real `lastSyncedAt` field. There is no * `lastSeen` field on the real PairedDevice; [updateLastSeen] folds into * `lastKnownIp` refresh + `lastSyncedAt` is left untouched. It is kept as a * no-op-friendly hook so callers from the brief compile; see integration note. */ @Singleton class PairedDeviceStore @Inject constructor( @ApplicationContext context: Context, ) : PairedDeviceRegistry { private val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() private val prefs = EncryptedSharedPreferences.create( context, PREFS_FILE, masterKey, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, ) private val listSerializer = ListSerializer(PairedDevice.serializer()) private val revocationSerializer = ListSerializer(DeviceRevocation.serializer()) override fun getAll(): List { val raw = prefs.getString(KEY_DEVICES, null) ?: return emptyList() return runCatching { json.decodeFromString(listSerializer, raw) }.getOrDefault(emptyList()) } override fun get(id: String): PairedDevice? = getAll().firstOrNull { it.deviceId == id } override fun getByPubkey(pubkeyHex: String): PairedDevice? = getAll().firstOrNull { it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true } /** Insert or replace by [PairedDevice.deviceId]. */ override fun upsert(device: PairedDevice) { // Peer-supplied names arrive capped so a hostile peer cannot grow the // registry without limit (adoption + direct-pair seams land here). val device = device.copy( deviceName = device.deviceName?.let(::capDeviceName), localAlias = get(device.deviceId)?.localAlias, ) val next = getAll().filterNot { it.deviceId == device.deviceId } + device persist(next) } fun rename(deviceId: String, name: String) { persist(withLocalAlias(getAll(), deviceId, name)) } override fun remove(deviceId: String) { persist(getAll().filterNot { it.deviceId == deviceId }) } /** * Commit a staged manifest in one synchronous SharedPreferences transaction. * `apply()` is appropriate for ordinary UI writes, but it would expose each * per-device edit independently to observers and could leave an old-session * manifest half-applied across a lock. `commit()` updates both keys together * in memory and does not return until the durable write has completed. */ override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean { val priorPrefs = readPrefsState() val currentDevices = getAll() val currentRevocations = listRevocations() val knownRevokedIds = currentRevocations.map { it.deviceId }.toSet() val freshRevocations = batch.incomingRevocations.filterNot { knownRevokedIds.contains(it.deviceId) } val nextRevocations = (currentRevocations + freshRevocations).takeLast(MAX_REVOCATIONS) val removed = batch.removals val nextDevices = currentDevices .filterNot { removed.contains(it.deviceId) } .toMutableList() batch.upserts.forEach { device -> val alias = nextDevices.firstOrNull { it.deviceId == device.deviceId }?.localAlias val device = device.copy( deviceName = device.deviceName?.let(::capDeviceName), localAlias = alias, ) nextDevices.removeAll { it.deviceId == device.deviceId } nextDevices += device } val nextPrefs = RegistryPrefsState( devices = json.encodeToString(listSerializer, nextDevices), revocations = json.encodeToString(revocationSerializer, nextRevocations), ) // Explicitly restore the old encrypted-preference values when commit() // reports false. The platform usually keeps them in memory, but a real // store failure must not rely on that undocumented outcome. return commitWithRollback(priorPrefs, nextPrefs, ::writePrefsState) } // ── Device revocation (Part 5 §C) ──────────────────────────────────────── override fun listRevocations(): List { val raw = prefs.getString(KEY_REVOCATIONS, null) ?: return emptyList() return runCatching { json.decodeFromString(revocationSerializer, raw) } .getOrDefault(emptyList()) } /** * Revoke a device: record it, then drop it from the registry. The record * rides out in every manifest this device signs from now on. A stale entry * may survive in the pending-vouch queue; the confirm path re-checks * revocation before promoting anything out of it. */ fun revoke( deviceId: String, revokedByDeviceId: String, nowMs: Long = System.currentTimeMillis(), ): DeviceRevocation? { val currentDevices = getAll() val currentRevocations = listRevocations() val mutation = planDeviceRevocation( devices = currentDevices, revocations = currentRevocations, deviceId = deviceId, revokedByDeviceId = revokedByDeviceId, nowMs = nowMs, ) if (mutation.revocations !== currentRevocations) { persistRevocations(mutation.revocations) } if (mutation.devices != currentDevices) { persist(mutation.devices) } return mutation.added } /** * `revokedAt` is peer-supplied and is NOT used for ordering — revocation is * terminal, so an incoming record can never displace one already held. */ override fun mergeRevocations(incoming: List): List { val existing = listRevocations() val known = existing.map { it.deviceId }.toSet() val fresh = incoming.filterNot { known.contains(it.deviceId) } if (fresh.isEmpty()) return emptyList() persistRevocations((existing + fresh).takeLast(MAX_REVOCATIONS)) return fresh.map { it.deviceId } } /** * Clear a revocation. The ONLY legitimate caller is a direct, SAS-confirmed * re-pair — a physically present user on both devices. Never call this from * a path a remote peer can reach. */ override fun clearRevocation(deviceId: String) { val existing = listRevocations() val filtered = existing.filterNot { it.deviceId == deviceId } if (filtered.size != existing.size) persistRevocations(filtered) } fun clear() { prefs.edit() .remove(KEY_DEVICES) .remove(KEY_REVOCATIONS) .apply() } /** * Refresh the device's `lastKnownIp` (the closest real analogue to the * brief's `lastSeen`). No-op when the id is unknown or [ip] is null. */ fun updateLastSeen(id: String, ip: String? = null) { val current = get(id) ?: return if (ip == null) return upsert(current.copy(lastKnownIp = ip)) } fun updateLastSync(id: String, at: Long = System.currentTimeMillis()) { val current = get(id) ?: return upsert(current.copy(lastSyncedAt = at)) } /** * Reconnect prefers each pair's relay over the global fallback, so a relay * change in Settings only reaches established pairs by being written into * them. Skips the write when nothing moved, so the observers behind the * device list do not churn on a refused change. */ fun propagateRelaySetting(mode: SyncMode, storedRelayUrl: String) { val current = getAll() val next = withRelaySetting(current, mode, storedRelayUrl) if (next != current) persist(next) } /** * Observe the full list. EncryptedSharedPreferences supports * OnSharedPreferenceChangeListener, so this emits on every persist — * including writes from other components in-process. */ fun observeAll(): Flow> = callbackFlow { trySend(getAll()) val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key -> if (key == KEY_DEVICES || key == null) trySend(getAll()) } prefs.registerOnSharedPreferenceChangeListener(listener) awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) } } private fun persistRevocations(records: List) { prefs.edit() .putString(KEY_REVOCATIONS, json.encodeToString(revocationSerializer, records)) .apply() } private fun persist(devices: List) { prefs.edit() .putString(KEY_DEVICES, json.encodeToString(listSerializer, devices)) .apply() } private data class RegistryPrefsState( val devices: String?, val revocations: String?, ) private fun readPrefsState(): RegistryPrefsState = RegistryPrefsState( devices = prefs.getString(KEY_DEVICES, null), revocations = prefs.getString(KEY_REVOCATIONS, null), ) private fun writePrefsState(state: RegistryPrefsState): Boolean { val edit = prefs.edit() if (state.devices == null) edit.remove(KEY_DEVICES) else edit.putString(KEY_DEVICES, state.devices) if (state.revocations == null) edit.remove(KEY_REVOCATIONS) else edit.putString(KEY_REVOCATIONS, state.revocations) return edit.commit() } private companion object { const val PREFS_FILE = "peach_paired_devices" const val KEY_DEVICES = "paired_devices" const val KEY_REVOCATIONS = "device_revocations" /** Bound the list so a hostile peer cannot grow storage without limit. */ const val MAX_REVOCATIONS = 500 val json = Json { encodeDefaults = true; ignoreUnknownKeys = true } } } internal fun withLocalAlias( devices: List, deviceId: String, name: String, ): List { val alias = name.trim() require(alias.isNotEmpty()) { "device name must not be empty" } require(alias.length <= MAX_DEVICE_NAME_CHARS) { "device name must be 64 chars or fewer" } require(devices.any { it.deviceId == deviceId }) { "device_not_found" } return devices.map { if (it.deviceId == deviceId) it.copy(localAlias = alias) else it } } internal data class DeviceRevocationMutation( val devices: List, val revocations: List, val added: DeviceRevocation?, ) /** * Compute one revoke transaction without Android storage so the impossible * "paired and already revoked" state can be regression-tested directly. * * Older builds could leave that overlap behind. A repeated Remove must still * drop the live row even though the terminal record itself is idempotent. */ internal fun planDeviceRevocation( devices: List, revocations: List, deviceId: String, revokedByDeviceId: String, nowMs: Long, ): DeviceRevocationMutation { val remaining = devices.filterNot { it.deviceId == deviceId } if (revocations.any { it.deviceId == deviceId }) { return DeviceRevocationMutation(remaining, revocations, null) } val device = devices.firstOrNull { it.deviceId == deviceId } val record = DeviceRevocation( deviceId = deviceId, longTermPublicKeyHex = device?.longTermPublicKeyHex ?: "0".repeat(64), revokedAt = nowMs, revokedByDeviceId = revokedByDeviceId, ) return DeviceRevocationMutation(remaining, revocations + record, record) } /** * The mesh relay is pushed out through the same gate that decides whether to * dial one: a relay this device refuses to use must not be handed to peers * either — pushing Peach's own URL out under a "Private relay" label would tell * the same lie one layer down. */ internal fun withRelaySetting( devices: List, mode: SyncMode, storedRelayUrl: String, ): List = when (val decision = relayInForce(mode, storedRelayUrl)) { is RelayDecision.Dial -> devices.map { it.copy(relayUrl = decision.relayUrl) } is RelayDecision.Refuse -> devices }