PairedDeviceStore.kt

14.5 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.builtins.ListSerializer11import kotlinx.serialization.json.Json12import javax.inject.Inject13import javax.inject.Singleton1415private const val MAX_REVOCATION_RECORDS = 5001617/**18 * Persistent registry of paired devices, encrypted at rest with19 * EncryptedSharedPreferences (same construction as DuressConfigStore /20 * LicenseRepository, v2.1.36). The whole list lives under a single key as a21 * kotlinx.serialization JSON array of the REAL [PairedDevice] (sync package).22 *23 * Reconciliation with the C7 brief:24 *  - The brief invents a fresh `PairedDevice(id, name, publicKey: ByteArray,25 *    lastSeen, trustStatus, lastSyncAt)`. That type does NOT exist in this26 *    shape. The real [PairedDevice] (PairedDevice.kt) keys on `deviceId`, holds27 *    the public key as `longTermPublicKeyHex` (hex, not ByteArray), and tracks28 *    `pairedAt` / `lastSyncedAt` / `lastKnownIp`. This store reuses it verbatim;29 *    Base64 publicKey storage is therefore unnecessary — the hex field is the30 *    canonical wire form already produced by PairingFrames.frameToPairedDevice.31 *  - The brief's `TrustStatus` enum does not exist either. The real model uses a32 *    nullable `trustSource: String?` (e.g. "direct", "manifest"). Trust state is33 *    expressed there. See C7-INTEGRATION.md for the trust-status delta.34 *  - `updateLastSync` here maps to the real `lastSyncedAt` field. There is no35 *    `lastSeen` field on the real PairedDevice; [updateLastSeen] folds into36 *    `lastKnownIp` refresh + `lastSyncedAt` is left untouched. It is kept as a37 *    no-op-friendly hook so callers from the brief compile; see integration note.38 */39@Singleton40class PairedDeviceStore @Inject constructor(41    @ApplicationContext context: Context,42) : PairedDeviceRegistry {43    private val masterKey = MasterKey.Builder(context)44        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)45        .build()4647    private val prefs = EncryptedSharedPreferences.create(48        context,49        PREFS_FILE,50        masterKey,51        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,52        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,53    )5455    private val listSerializer = ListSerializer(PairedDevice.serializer())56    private val revocationSerializer = ListSerializer(DeviceRevocation.serializer())5758    override fun getAll(): List<PairedDevice> {59        val raw = prefs.getString(KEY_DEVICES, null) ?: return emptyList()60        return runCatching { json.decodeFromString(listSerializer, raw) }.getOrDefault(emptyList())61    }6263    override fun get(id: String): PairedDevice? = getAll().firstOrNull { it.deviceId == id }6465    override fun getByPubkey(pubkeyHex: String): PairedDevice? =66        getAll().firstOrNull {67            it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true68        }6970    /** Insert or replace by [PairedDevice.deviceId]. */71    override fun upsert(device: PairedDevice) {72        // Peer-supplied names arrive capped so a hostile peer cannot grow the73        // registry without limit (adoption + direct-pair seams land here).74        val device = device.copy(75            deviceName = device.deviceName?.let(::capDeviceName),76            localAlias = get(device.deviceId)?.localAlias,77        )78        val next = getAll().filterNot { it.deviceId == device.deviceId } + device79        persist(next)80    }8182    fun rename(deviceId: String, name: String) {83        persist(withLocalAlias(getAll(), deviceId, name))84    }8586    override fun remove(deviceId: String) {87        persist(getAll().filterNot { it.deviceId == deviceId })88    }8990    /**91     * Commit a staged manifest in one synchronous SharedPreferences transaction.92     * `apply()` is appropriate for ordinary UI writes, but it would expose each93     * per-device edit independently to observers and could leave an old-session94     * manifest half-applied across a lock. `commit()` updates both keys together95     * in memory and does not return until the durable write has completed.96     */97    override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean {98        val priorPrefs = readPrefsState()99        val currentDevices = getAll()100        val currentRevocations = listRevocations()101        val nextRevocations = mergeRevocationRecords(currentRevocations, batch.incomingRevocations).records102        val removed = batch.removals103        val nextDevices = currentDevices104            .filterNot { removed.contains(it.deviceId) }105            .toMutableList()106        batch.upserts.forEach { device ->107            val alias = nextDevices.firstOrNull { it.deviceId == device.deviceId }?.localAlias108            val device = device.copy(109                deviceName = device.deviceName?.let(::capDeviceName),110                localAlias = alias,111            )112            nextDevices.removeAll { it.deviceId == device.deviceId }113            nextDevices += device114        }115116        val nextPrefs = RegistryPrefsState(117            devices = json.encodeToString(listSerializer, nextDevices),118            revocations = json.encodeToString(revocationSerializer, nextRevocations),119        )120        // Explicitly restore the old encrypted-preference values when commit()121        // reports false. The platform usually keeps them in memory, but a real122        // store failure must not rely on that undocumented outcome.123        return commitWithRollback(priorPrefs, nextPrefs, ::writePrefsState)124    }125126    // ── Device revocation (Part 5 §C) ────────────────────────────────────────127128    override fun listRevocations(): List<DeviceRevocation> {129        val raw = prefs.getString(KEY_REVOCATIONS, null) ?: return emptyList()130        return runCatching { json.decodeFromString(revocationSerializer, raw) }131            .getOrDefault(emptyList())132    }133134    /**135     * Revoke a device: record it, then drop it from the registry. The record136     * rides out in every manifest this device signs from now on. A stale entry137     * may survive in the pending-vouch queue; the confirm path re-checks138     * revocation before promoting anything out of it.139     */140    fun revoke(141        deviceId: String,142        revokedByDeviceId: String,143        nowMs: Long = System.currentTimeMillis(),144    ): DeviceRevocation? {145        val currentDevices = getAll()146        val currentRevocations = listRevocations()147        val mutation = planDeviceRevocation(148            devices = currentDevices,149            revocations = currentRevocations,150            deviceId = deviceId,151            revokedByDeviceId = revokedByDeviceId,152            nowMs = nowMs,153        )154        if (mutation.revocations !== currentRevocations) {155            persistRevocations(mutation.revocations)156        }157        if (mutation.devices != currentDevices) {158            persist(mutation.devices)159        }160        return mutation.added161    }162163    /**164     * `revokedAt` is peer-supplied and is NOT used for ordering — revocation is165     * terminal, so an incoming record can never displace one already held.166     */167    override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {168        val existing = listRevocations()169        val merged = mergeRevocationRecords(existing, incoming)170        if (merged.records != existing) persistRevocations(merged.records)171        return merged.admitted.map { it.deviceId }172    }173174    /**175     * Clear a revocation. The ONLY legitimate caller is a direct, SAS-confirmed176     * re-pair — a physically present user on both devices. Never call this from177     * a path a remote peer can reach.178     */179    override fun clearRevocation(deviceId: String) {180        val existing = listRevocations()181        val filtered = existing.filterNot { it.deviceId == deviceId }182        if (filtered.size != existing.size) persistRevocations(filtered)183    }184185    fun clear() {186        prefs.edit()187            .remove(KEY_DEVICES)188            .remove(KEY_REVOCATIONS)189            .apply()190    }191192    /**193     * Refresh the device's `lastKnownIp` (the closest real analogue to the194     * brief's `lastSeen`). No-op when the id is unknown or [ip] is null.195     */196    fun updateLastSeen(id: String, ip: String? = null) {197        val current = get(id) ?: return198        if (ip == null) return199        upsert(current.copy(lastKnownIp = ip))200    }201202    fun updateLastSync(id: String, at: Long = System.currentTimeMillis()) {203        val current = get(id) ?: return204        upsert(current.copy(lastSyncedAt = at))205    }206207    /**208     * Reconnect prefers each pair's relay over the global fallback, so a relay209     * change in Settings only reaches established pairs by being written into210     * them. Skips the write when nothing moved, so the observers behind the211     * device list do not churn on a refused change.212     */213    fun propagateRelaySetting(mode: SyncMode, storedRelayUrl: String) {214        val current = getAll()215        val next = withRelaySetting(current, mode, storedRelayUrl)216        if (next != current) persist(next)217    }218219    /**220     * Observe the full list. EncryptedSharedPreferences supports221     * OnSharedPreferenceChangeListener, so this emits on every persist —222     * including writes from other components in-process.223     */224    fun observeAll(): Flow<List<PairedDevice>> = callbackFlow {225        trySend(getAll())226        val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->227            if (key == KEY_DEVICES || key == null) trySend(getAll())228        }229        prefs.registerOnSharedPreferenceChangeListener(listener)230        awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }231    }232233    private fun persistRevocations(records: List<DeviceRevocation>) {234        prefs.edit()235            .putString(KEY_REVOCATIONS, json.encodeToString(revocationSerializer, records))236            .apply()237    }238239    private fun persist(devices: List<PairedDevice>) {240        prefs.edit()241            .putString(KEY_DEVICES, json.encodeToString(listSerializer, devices))242            .apply()243    }244245    private data class RegistryPrefsState(246        val devices: String?,247        val revocations: String?,248    )249250    private fun readPrefsState(): RegistryPrefsState = RegistryPrefsState(251        devices = prefs.getString(KEY_DEVICES, null),252        revocations = prefs.getString(KEY_REVOCATIONS, null),253    )254255    private fun writePrefsState(state: RegistryPrefsState): Boolean {256        val edit = prefs.edit()257        if (state.devices == null) edit.remove(KEY_DEVICES) else edit.putString(KEY_DEVICES, state.devices)258        if (state.revocations == null) edit.remove(KEY_REVOCATIONS) else edit.putString(KEY_REVOCATIONS, state.revocations)259        return edit.commit()260    }261262    private companion object {263        const val PREFS_FILE = "peach_paired_devices"264        const val KEY_DEVICES = "paired_devices"265        const val KEY_REVOCATIONS = "device_revocations"266        /** Bound the list so a hostile peer cannot grow storage without limit. */267        const val MAX_REVOCATIONS = MAX_REVOCATION_RECORDS268        val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }269    }270}271272internal fun withLocalAlias(273    devices: List<PairedDevice>,274    deviceId: String,275    name: String,276): List<PairedDevice> {277    val alias = name.trim()278    require(alias.isNotEmpty()) { "device name must not be empty" }279    require(alias.length <= MAX_DEVICE_NAME_CHARS) { "device name must be 64 chars or fewer" }280    require(devices.any { it.deviceId == deviceId }) { "device_not_found" }281    return devices.map { if (it.deviceId == deviceId) it.copy(localAlias = alias) else it }282}283284internal data class DeviceRevocationMutation(285    val devices: List<PairedDevice>,286    val revocations: List<DeviceRevocation>,287    val added: DeviceRevocation?,288)289290/**291 * Compute one revoke transaction without Android storage so the impossible292 * "paired and already revoked" state can be regression-tested directly.293 *294 * Older builds could leave that overlap behind. A repeated Remove must still295 * drop the live row even though the terminal record itself is idempotent.296 */297internal fun planDeviceRevocation(298    devices: List<PairedDevice>,299    revocations: List<DeviceRevocation>,300    deviceId: String,301    revokedByDeviceId: String,302    nowMs: Long,303): DeviceRevocationMutation {304    val remaining = devices.filterNot { it.deviceId == deviceId }305    if (revocations.any { it.deviceId == deviceId }) {306        return DeviceRevocationMutation(remaining, revocations, null)307    }308    val device = devices.firstOrNull { it.deviceId == deviceId }309    val record = DeviceRevocation(310        deviceId = deviceId,311        longTermPublicKeyHex = device?.longTermPublicKeyHex ?: "0".repeat(64),312        revokedAt = nowMs,313        revokedByDeviceId = revokedByDeviceId,314    )315    val retained = if (revocations.size < MAX_REVOCATION_RECORDS) {316        revocations317    } else {318        val removable = revocations.indexOfFirst { it.revokedByDeviceId != revokedByDeviceId }319        if (removable >= 0) revocations.toMutableList().also { it.removeAt(removable) } else revocations320    }321    return DeviceRevocationMutation(322        remaining,323        (retained + record).takeLast(MAX_REVOCATION_RECORDS),324        record,325    )326}327328internal data class RevocationMergeResult(329    val records: List<DeviceRevocation>,330    val admitted: List<DeviceRevocation>,331)332333internal fun mergeRevocationRecords(334    existing: List<DeviceRevocation>,335    incoming: List<DeviceRevocation>,336): RevocationMergeResult {337    val known = existing.map { it.deviceId }.toSet()338    val fresh = incoming.filterNot { known.contains(it.deviceId) }339    val admitted = fresh.take((MAX_REVOCATION_RECORDS - existing.size).coerceAtLeast(0))340    return RevocationMergeResult(existing + admitted, admitted)341}342343/**344 * The mesh relay is pushed out through the same gate that decides whether to345 * dial one: a relay this device refuses to use must not be handed to peers346 * either — pushing Peach's own URL out under a "Private relay" label would tell347 * the same lie one layer down.348 */349internal fun withRelaySetting(350    devices: List<PairedDevice>,351    mode: SyncMode,352    storedRelayUrl: String,353): List<PairedDevice> = when (val decision = relayInForce(mode, storedRelayUrl)) {354    is RelayDecision.Dial -> devices.map { it.copy(relayUrl = decision.relayUrl) }355    is RelayDecision.Refuse -> devices356}