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.Serializable import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton @Serializable data class PendingVouchedDevice( val deviceId: String, val deviceName: String, val deviceType: String, val longTermPublicKeyHex: String, val signingPublicKeyHex: String, val wsPort: Int? = null, val pairedAt: Long, val receivedAt: Long, val vouchedByDeviceId: String, ) interface PendingVouchedRegistry { fun getAll(): List fun upsert(device: PendingVouchedDevice) /** Persist one pending introduction and report whether the durable commit succeeded. */ fun upsertAndReport(device: PendingVouchedDevice): Boolean { return runCatching { upsert(device) true }.getOrDefault(false) } /** * Remove one pending introduction and report whether the durable commit * succeeded. The default preserves compatibility for in-memory registries * that only implement the legacy void removal API. */ fun removeAndReport(id: String): Boolean { return runCatching { remove(id) true }.getOrDefault(false) } /** Legacy removal API retained for existing registry callers. */ fun remove(id: String) /** * Clear a pending introduction by its validated identity tuple rather than * its device id. A device id alone can name two holders once the same id is * re-presented with different keys, so adoption v2 must not clear by id. */ fun removeIdentityAndReport(device: PairedDevice): Boolean { return runCatching { remove(device.deviceId) true }.getOrDefault(false) } /** * Remove a set of pending introductions synchronously and report failure. * Implementations must linearize the whole batch; the default exists only * for small in-memory test registries that have no durable store. */ fun removeBatch(ids: Set): Boolean { return runCatching { ids.forEach(::remove) true }.getOrDefault(false) } /** Restore a previously captured pending set after a later trust commit fails. */ fun restoreBatch(devices: List): Boolean { return runCatching { devices.forEach(::upsert) true }.getOrDefault(false) } } @Singleton class PendingVouchedDeviceStore @Inject constructor( @ApplicationContext context: Context, ) : PendingVouchedRegistry { 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(PendingVouchedDevice.serializer()) override fun getAll(): List { val raw = prefs.getString(KEY_PENDING, null) ?: return emptyList() return runCatching { json.decodeFromString(listSerializer, raw) }.getOrDefault(emptyList()) } /** * Finds a pending holder only when all durable identity components match. * Device ids are user-visible labels and are not sufficient authentication. */ fun get( deviceId: String, longTermPublicKeyHex: String, signingPublicKeyHex: String, ): PendingVouchedDevice? = getAll().firstOrNull { it.deviceId == deviceId && it.longTermPublicKeyHex == longTermPublicKeyHex && it.signingPublicKeyHex == signingPublicKeyHex } /** Legacy UI lookup; adoption v2 uses the identity-keyed overload above. */ fun get(id: String): PendingVouchedDevice? = getAll().firstOrNull { it.deviceId == id } override fun upsert(device: PendingVouchedDevice) { upsertAndReport(device) } override fun upsertAndReport(device: PendingVouchedDevice): Boolean { // A pending holder is replaced only by the same validated identity tuple. val normalized = device.copy(deviceName = capDeviceName(device.deviceName)) val next = ( getAll().filterNot { it.deviceId == normalized.deviceId && it.longTermPublicKeyHex == normalized.longTermPublicKeyHex && it.signingPublicKeyHex == normalized.signingPublicKeyHex } + normalized ).takeLast(MAX_PENDING) return commitList(next) } override fun remove(id: String) { removeAndReport(id) } override fun removeAndReport(id: String): Boolean { val before = getAll() val after = before.filterNot { it.deviceId == id } return commitWithRollback(before, after, ::commitList) } override fun removeIdentityAndReport(device: PairedDevice): Boolean { val before = getAll() val after = before.filterNot { it.deviceId == device.deviceId && it.longTermPublicKeyHex == device.longTermPublicKeyHex && it.signingPublicKeyHex == device.signingPublicKeyHex } return commitWithRollback(before, after, ::commitList) } /** * Remove a set of pending introductions synchronously and report failure. * Implementations must linearize the whole batch; the default exists only * for small in-memory test registries that have no durable store. */ override fun removeBatch(ids: Set): Boolean { if (ids.isEmpty()) return true val before = getAll() val after = before.filterNot { it.deviceId in ids } return commitWithRollback(before, after, ::commitList) } override fun restoreBatch(devices: List): Boolean { if (devices.isEmpty()) return true val before = getAll() val restoredIds = devices.map { it.deviceId }.toSet() val after = (before.filterNot { it.deviceId in restoredIds } + devices).takeLast(MAX_PENDING) return commitWithRollback(before, after, ::commitList) } fun clear() { persist(emptyList()) } /** Verbatim logical copy of the pending registry, for the secure-reset archive. */ internal fun snapshot(): Map = prefs.all fun observeAll(): Flow> = callbackFlow { trySend(getAll()) val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key -> if (key == KEY_PENDING || key == null) trySend(getAll()) } prefs.registerOnSharedPreferenceChangeListener(listener) awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) } } private fun persist(devices: List) { prefs.edit() .putString(KEY_PENDING, json.encodeToString(listSerializer, devices)) // Pending introductions participate in fail-closed trust ordering; // an outstanding async apply could replay a stale row after a // synchronous revocation/removal commit. Keep every write in this // store synchronous so removal cannot be overwritten by a queued // disk task after a crash. .commit() } /** Synchronous writer used by the fail-closed batch/rollback paths. */ private fun commitList(devices: List): Boolean = prefs.edit() .putString(KEY_PENDING, json.encodeToString(listSerializer, devices)) .commit() private companion object { const val PREFS_FILE = "peach_pending_vouched_devices" const val KEY_PENDING = "pending_vouched_devices" const val MAX_PENDING = 50 val json = Json { encodeDefaults = true; ignoreUnknownKeys = true } } } /** * Apply a durable state transition, explicitly restoring the prior in-process * value when the forward commit reports failure. Android's SharedPreferences * contract normally leaves the old value in memory on `commit()==false`, but * relying on that implementation detail made failure injection indistinguishable * from a successful transaction. Both registry stores use this helper. */ internal fun commitWithRollback( before: T, after: T, commit: (T) -> Boolean, ): Boolean { val applied = runCatching { commit(after) }.getOrDefault(false) if (applied) return true runCatching { commit(before) } return false }