PendingVouchedDeviceStore.kt

5.9 KB · raw

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.Serializable11import kotlinx.serialization.builtins.ListSerializer12import kotlinx.serialization.json.Json13import javax.inject.Inject14import javax.inject.Singleton1516@Serializable17data class PendingVouchedDevice(18    val deviceId: String,19    val deviceName: String,20    val deviceType: String,21    val longTermPublicKeyHex: String,22    val signingPublicKeyHex: String,23    val wsPort: Int? = null,24    val pairedAt: Long,25    val receivedAt: Long,26    val vouchedByDeviceId: String,27)2829interface PendingVouchedRegistry {30    fun getAll(): List<PendingVouchedDevice>31    fun upsert(device: PendingVouchedDevice)32    fun remove(id: String)3334    /**35     * Remove a set of pending introductions synchronously and report failure.36     * Implementations must linearize the whole batch; the default exists only37     * for small in-memory test registries that have no durable store.38     */39    fun removeBatch(ids: Set<String>): Boolean {40        return runCatching {41            ids.forEach(::remove)42            true43        }.getOrDefault(false)44    }4546    /** Restore a previously captured pending set after a later trust commit fails. */47    fun restoreBatch(devices: List<PendingVouchedDevice>): Boolean {48        return runCatching {49            devices.forEach(::upsert)50            true51        }.getOrDefault(false)52    }53}5455@Singleton56class PendingVouchedDeviceStore @Inject constructor(57    @ApplicationContext context: Context,58) : PendingVouchedRegistry {59    private val masterKey = MasterKey.Builder(context)60        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)61        .build()6263    private val prefs = EncryptedSharedPreferences.create(64        context,65        PREFS_FILE,66        masterKey,67        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,68        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,69    )7071    private val listSerializer = ListSerializer(PendingVouchedDevice.serializer())7273    override fun getAll(): List<PendingVouchedDevice> {74        val raw = prefs.getString(KEY_PENDING, null) ?: return emptyList()75        return runCatching { json.decodeFromString(listSerializer, raw) }.getOrDefault(emptyList())76    }7778    fun get(id: String): PendingVouchedDevice? = getAll().firstOrNull { it.deviceId == id }7980    override fun upsert(device: PendingVouchedDevice) {81        // Vouch requests carry a peer-chosen name; bound it like every other82        // inbound name so the pending queue cannot grow without limit.83        val device = device.copy(deviceName = capDeviceName(device.deviceName))84        val next = (getAll().filterNot { it.deviceId == device.deviceId } + device).takeLast(MAX_PENDING)85        persist(next)86    }8788    override fun remove(id: String) {89        persist(getAll().filterNot { it.deviceId == id })90    }9192    override fun removeBatch(ids: Set<String>): Boolean {93        if (ids.isEmpty()) return true94        val before = getAll()95        val after = before.filterNot { it.deviceId in ids }96        return commitWithRollback(before, after, ::commitList)97    }9899    override fun restoreBatch(devices: List<PendingVouchedDevice>): Boolean {100        if (devices.isEmpty()) return true101        val before = getAll()102        val restoredIds = devices.map { it.deviceId }.toSet()103        val after = (before.filterNot { it.deviceId in restoredIds } + devices).takeLast(MAX_PENDING)104        return commitWithRollback(before, after, ::commitList)105    }106107    fun clear() {108        persist(emptyList())109    }110111    fun observeAll(): Flow<List<PendingVouchedDevice>> = callbackFlow {112        trySend(getAll())113        val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->114            if (key == KEY_PENDING || key == null) trySend(getAll())115        }116        prefs.registerOnSharedPreferenceChangeListener(listener)117        awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }118    }119120    private fun persist(devices: List<PendingVouchedDevice>) {121        prefs.edit()122            .putString(KEY_PENDING, json.encodeToString(listSerializer, devices))123            // Pending introductions participate in fail-closed trust ordering;124            // an outstanding async apply could replay a stale row after a125            // synchronous revocation/removal commit. Keep every write in this126            // store synchronous so removal cannot be overwritten by a queued127            // disk task after a crash.128            .commit()129    }130131    /** Synchronous writer used by the fail-closed batch/rollback paths. */132    private fun commitList(devices: List<PendingVouchedDevice>): Boolean =133        prefs.edit()134            .putString(KEY_PENDING, json.encodeToString(listSerializer, devices))135            .commit()136137    private companion object {138        const val PREFS_FILE = "peach_pending_vouched_devices"139        const val KEY_PENDING = "pending_vouched_devices"140        const val MAX_PENDING = 50141        val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }142    }143}144145/**146 * Apply a durable state transition, explicitly restoring the prior in-process147 * value when the forward commit reports failure. Android's SharedPreferences148 * contract normally leaves the old value in memory on `commit()==false`, but149 * relying on that implementation detail made failure injection indistinguishable150 * from a successful transaction. Both registry stores use this helper.151 */152internal fun <T> commitWithRollback(153    before: T,154    after: T,155    commit: (T) -> Boolean,156): Boolean {157    val applied = runCatching { commit(after) }.getOrDefault(false)158    if (applied) return true159    runCatching { commit(before) }160    return false161}