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) { val next = getAll().filterNot { it.deviceId == device.deviceId } + device persist(next) } override fun remove(deviceId: String) { persist(getAll().filterNot { it.deviceId == deviceId }) } // ── 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).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 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 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 }