PendingVouchedDeviceStore.kt

8.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    /** Persist one pending introduction and report whether the durable commit succeeded. */33    fun upsertAndReport(device: PendingVouchedDevice): Boolean {34        return runCatching {35            upsert(device)36            true37        }.getOrDefault(false)38    }39    /**40     * Remove one pending introduction and report whether the durable commit41     * succeeded. The default preserves compatibility for in-memory registries42     * that only implement the legacy void removal API.43     */44    fun removeAndReport(id: String): Boolean {45        return runCatching {46            remove(id)47            true48        }.getOrDefault(false)49    }5051    /** Legacy removal API retained for existing registry callers. */52    fun remove(id: String)5354    /**55     * Clear a pending introduction by its validated identity tuple rather than56     * its device id. A device id alone can name two holders once the same id is57     * re-presented with different keys, so adoption v2 must not clear by id.58     */59    fun removeIdentityAndReport(device: PairedDevice): Boolean {60        return runCatching {61            remove(device.deviceId)62            true63        }.getOrDefault(false)64    }6566    /**67     * Remove a set of pending introductions synchronously and report failure.68     * Implementations must linearize the whole batch; the default exists only69     * for small in-memory test registries that have no durable store.70     */71    fun removeBatch(ids: Set<String>): Boolean {72        return runCatching {73            ids.forEach(::remove)74            true75        }.getOrDefault(false)76    }7778    /** Restore a previously captured pending set after a later trust commit fails. */79    fun restoreBatch(devices: List<PendingVouchedDevice>): Boolean {80        return runCatching {81            devices.forEach(::upsert)82            true83        }.getOrDefault(false)84    }85}8687@Singleton88class PendingVouchedDeviceStore @Inject constructor(89    @ApplicationContext context: Context,90) : PendingVouchedRegistry {91    private val masterKey = MasterKey.Builder(context)92        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)93        .build()9495    private val prefs = EncryptedSharedPreferences.create(96        context,97        PREFS_FILE,98        masterKey,99        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,100        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,101    )102103    private val listSerializer = ListSerializer(PendingVouchedDevice.serializer())104105    override fun getAll(): List<PendingVouchedDevice> {106        val raw = prefs.getString(KEY_PENDING, null) ?: return emptyList()107        return runCatching { json.decodeFromString(listSerializer, raw) }.getOrDefault(emptyList())108    }109110    /**111     * Finds a pending holder only when all durable identity components match.112     * Device ids are user-visible labels and are not sufficient authentication.113     */114    fun get(115        deviceId: String,116        longTermPublicKeyHex: String,117        signingPublicKeyHex: String,118    ): PendingVouchedDevice? = getAll().firstOrNull {119        it.deviceId == deviceId &&120            it.longTermPublicKeyHex == longTermPublicKeyHex &&121            it.signingPublicKeyHex == signingPublicKeyHex122    }123124    /** Legacy UI lookup; adoption v2 uses the identity-keyed overload above. */125    fun get(id: String): PendingVouchedDevice? = getAll().firstOrNull { it.deviceId == id }126127    override fun upsert(device: PendingVouchedDevice) {128        upsertAndReport(device)129    }130131    override fun upsertAndReport(device: PendingVouchedDevice): Boolean {132        // A pending holder is replaced only by the same validated identity tuple.133        val normalized = device.copy(deviceName = capDeviceName(device.deviceName))134        val next = (135            getAll().filterNot {136                it.deviceId == normalized.deviceId &&137                    it.longTermPublicKeyHex == normalized.longTermPublicKeyHex &&138                    it.signingPublicKeyHex == normalized.signingPublicKeyHex139            } + normalized140            ).takeLast(MAX_PENDING)141        return commitList(next)142    }143144    override fun remove(id: String) {145        removeAndReport(id)146    }147148    override fun removeAndReport(id: String): Boolean {149        val before = getAll()150        val after = before.filterNot { it.deviceId == id }151        return commitWithRollback(before, after, ::commitList)152    }153154    override fun removeIdentityAndReport(device: PairedDevice): Boolean {155        val before = getAll()156        val after = before.filterNot {157            it.deviceId == device.deviceId &&158                it.longTermPublicKeyHex == device.longTermPublicKeyHex &&159                it.signingPublicKeyHex == device.signingPublicKeyHex160        }161        return commitWithRollback(before, after, ::commitList)162    }163164    /**165     * Remove a set of pending introductions synchronously and report failure.166     * Implementations must linearize the whole batch; the default exists only167     * for small in-memory test registries that have no durable store.168     */169    override fun removeBatch(ids: Set<String>): Boolean {170        if (ids.isEmpty()) return true171        val before = getAll()172        val after = before.filterNot { it.deviceId in ids }173        return commitWithRollback(before, after, ::commitList)174    }175176    override fun restoreBatch(devices: List<PendingVouchedDevice>): Boolean {177        if (devices.isEmpty()) return true178        val before = getAll()179        val restoredIds = devices.map { it.deviceId }.toSet()180        val after = (before.filterNot { it.deviceId in restoredIds } + devices).takeLast(MAX_PENDING)181        return commitWithRollback(before, after, ::commitList)182    }183184    fun clear() {185        persist(emptyList())186    }187188    /** Verbatim logical copy of the pending registry, for the secure-reset archive. */189    internal fun snapshot(): Map<String, Any?> = prefs.all190191    fun observeAll(): Flow<List<PendingVouchedDevice>> = callbackFlow {192        trySend(getAll())193        val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->194            if (key == KEY_PENDING || key == null) trySend(getAll())195        }196        prefs.registerOnSharedPreferenceChangeListener(listener)197        awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }198    }199200    private fun persist(devices: List<PendingVouchedDevice>) {201        prefs.edit()202            .putString(KEY_PENDING, json.encodeToString(listSerializer, devices))203            // Pending introductions participate in fail-closed trust ordering;204            // an outstanding async apply could replay a stale row after a205            // synchronous revocation/removal commit. Keep every write in this206            // store synchronous so removal cannot be overwritten by a queued207            // disk task after a crash.208            .commit()209    }210211    /** Synchronous writer used by the fail-closed batch/rollback paths. */212    private fun commitList(devices: List<PendingVouchedDevice>): Boolean =213        prefs.edit()214            .putString(KEY_PENDING, json.encodeToString(listSerializer, devices))215            .commit()216217    private companion object {218        const val PREFS_FILE = "peach_pending_vouched_devices"219        const val KEY_PENDING = "pending_vouched_devices"220        const val MAX_PENDING = 50221        val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }222    }223}224225/**226 * Apply a durable state transition, explicitly restoring the prior in-process227 * value when the forward commit reports failure. Android's SharedPreferences228 * contract normally leaves the old value in memory on `commit()==false`, but229 * relying on that implementation detail made failure injection indistinguishable230 * from a successful transaction. Both registry stores use this helper.231 */232internal fun <T> commitWithRollback(233    before: T,234    after: T,235    commit: (T) -> Boolean,236): Boolean {237    val applied = runCatching { commit(after) }.getOrDefault(false)238    if (applied) return true239    runCatching { commit(before) }240    return false241}