PairedDeviceStore.kt

14.4 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 knownRevokedIds = currentRevocations.map { it.deviceId }.toSet()102        val freshRevocations = batch.incomingRevocations.filterNot { knownRevokedIds.contains(it.deviceId) }103        val admittedRevocations = freshRevocations.take((MAX_REVOCATIONS - currentRevocations.size).coerceAtLeast(0))104        val nextRevocations = currentRevocations + admittedRevocations105        val removed = batch.removals106        val nextDevices = currentDevices107            .filterNot { removed.contains(it.deviceId) }108            .toMutableList()109        batch.upserts.forEach { device ->110            val alias = nextDevices.firstOrNull { it.deviceId == device.deviceId }?.localAlias111            val device = device.copy(112                deviceName = device.deviceName?.let(::capDeviceName),113                localAlias = alias,114            )115            nextDevices.removeAll { it.deviceId == device.deviceId }116            nextDevices += device117        }118119        val nextPrefs = RegistryPrefsState(120            devices = json.encodeToString(listSerializer, nextDevices),121            revocations = json.encodeToString(revocationSerializer, nextRevocations),122        )123        // Explicitly restore the old encrypted-preference values when commit()124        // reports false. The platform usually keeps them in memory, but a real125        // store failure must not rely on that undocumented outcome.126        return commitWithRollback(priorPrefs, nextPrefs, ::writePrefsState)127    }128129    // ── Device revocation (Part 5 §C) ────────────────────────────────────────130131    override fun listRevocations(): List<DeviceRevocation> {132        val raw = prefs.getString(KEY_REVOCATIONS, null) ?: return emptyList()133        return runCatching { json.decodeFromString(revocationSerializer, raw) }134            .getOrDefault(emptyList())135    }136137    /**138     * Revoke a device: record it, then drop it from the registry. The record139     * rides out in every manifest this device signs from now on. A stale entry140     * may survive in the pending-vouch queue; the confirm path re-checks141     * revocation before promoting anything out of it.142     */143    fun revoke(144        deviceId: String,145        revokedByDeviceId: String,146        nowMs: Long = System.currentTimeMillis(),147    ): DeviceRevocation? {148        val currentDevices = getAll()149        val currentRevocations = listRevocations()150        val mutation = planDeviceRevocation(151            devices = currentDevices,152            revocations = currentRevocations,153            deviceId = deviceId,154            revokedByDeviceId = revokedByDeviceId,155            nowMs = nowMs,156        )157        if (mutation.revocations !== currentRevocations) {158            persistRevocations(mutation.revocations)159        }160        if (mutation.devices != currentDevices) {161            persist(mutation.devices)162        }163        return mutation.added164    }165166    /**167     * `revokedAt` is peer-supplied and is NOT used for ordering — revocation is168     * terminal, so an incoming record can never displace one already held.169     */170    override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {171        val existing = listRevocations()172        val known = existing.map { it.deviceId }.toSet()173        val fresh = incoming.filterNot { known.contains(it.deviceId) }174        if (fresh.isEmpty()) return emptyList()175        val admitted = fresh.take((MAX_REVOCATIONS - existing.size).coerceAtLeast(0))176        if (admitted.isNotEmpty()) persistRevocations(existing + admitted)177        return admitted.map { it.deviceId }178    }179180    /**181     * Clear a revocation. The ONLY legitimate caller is a direct, SAS-confirmed182     * re-pair — a physically present user on both devices. Never call this from183     * a path a remote peer can reach.184     */185    override fun clearRevocation(deviceId: String) {186        val existing = listRevocations()187        val filtered = existing.filterNot { it.deviceId == deviceId }188        if (filtered.size != existing.size) persistRevocations(filtered)189    }190191    fun clear() {192        prefs.edit()193            .remove(KEY_DEVICES)194            .remove(KEY_REVOCATIONS)195            .apply()196    }197198    /**199     * Refresh the device's `lastKnownIp` (the closest real analogue to the200     * brief's `lastSeen`). No-op when the id is unknown or [ip] is null.201     */202    fun updateLastSeen(id: String, ip: String? = null) {203        val current = get(id) ?: return204        if (ip == null) return205        upsert(current.copy(lastKnownIp = ip))206    }207208    fun updateLastSync(id: String, at: Long = System.currentTimeMillis()) {209        val current = get(id) ?: return210        upsert(current.copy(lastSyncedAt = at))211    }212213    /**214     * Reconnect prefers each pair's relay over the global fallback, so a relay215     * change in Settings only reaches established pairs by being written into216     * them. Skips the write when nothing moved, so the observers behind the217     * device list do not churn on a refused change.218     */219    fun propagateRelaySetting(mode: SyncMode, storedRelayUrl: String) {220        val current = getAll()221        val next = withRelaySetting(current, mode, storedRelayUrl)222        if (next != current) persist(next)223    }224225    /**226     * Observe the full list. EncryptedSharedPreferences supports227     * OnSharedPreferenceChangeListener, so this emits on every persist —228     * including writes from other components in-process.229     */230    fun observeAll(): Flow<List<PairedDevice>> = callbackFlow {231        trySend(getAll())232        val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->233            if (key == KEY_DEVICES || key == null) trySend(getAll())234        }235        prefs.registerOnSharedPreferenceChangeListener(listener)236        awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }237    }238239    private fun persistRevocations(records: List<DeviceRevocation>) {240        prefs.edit()241            .putString(KEY_REVOCATIONS, json.encodeToString(revocationSerializer, records))242            .apply()243    }244245    private fun persist(devices: List<PairedDevice>) {246        prefs.edit()247            .putString(KEY_DEVICES, json.encodeToString(listSerializer, devices))248            .apply()249    }250251    private data class RegistryPrefsState(252        val devices: String?,253        val revocations: String?,254    )255256    private fun readPrefsState(): RegistryPrefsState = RegistryPrefsState(257        devices = prefs.getString(KEY_DEVICES, null),258        revocations = prefs.getString(KEY_REVOCATIONS, null),259    )260261    private fun writePrefsState(state: RegistryPrefsState): Boolean {262        val edit = prefs.edit()263        if (state.devices == null) edit.remove(KEY_DEVICES) else edit.putString(KEY_DEVICES, state.devices)264        if (state.revocations == null) edit.remove(KEY_REVOCATIONS) else edit.putString(KEY_REVOCATIONS, state.revocations)265        return edit.commit()266    }267268    private companion object {269        const val PREFS_FILE = "peach_paired_devices"270        const val KEY_DEVICES = "paired_devices"271        const val KEY_REVOCATIONS = "device_revocations"272        /** Bound the list so a hostile peer cannot grow storage without limit. */273        const val MAX_REVOCATIONS = MAX_REVOCATION_RECORDS274        val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }275    }276}277278internal fun withLocalAlias(279    devices: List<PairedDevice>,280    deviceId: String,281    name: String,282): List<PairedDevice> {283    val alias = name.trim()284    require(alias.isNotEmpty()) { "device name must not be empty" }285    require(alias.length <= MAX_DEVICE_NAME_CHARS) { "device name must be 64 chars or fewer" }286    require(devices.any { it.deviceId == deviceId }) { "device_not_found" }287    return devices.map { if (it.deviceId == deviceId) it.copy(localAlias = alias) else it }288}289290internal data class DeviceRevocationMutation(291    val devices: List<PairedDevice>,292    val revocations: List<DeviceRevocation>,293    val added: DeviceRevocation?,294)295296/**297 * Compute one revoke transaction without Android storage so the impossible298 * "paired and already revoked" state can be regression-tested directly.299 *300 * Older builds could leave that overlap behind. A repeated Remove must still301 * drop the live row even though the terminal record itself is idempotent.302 */303internal fun planDeviceRevocation(304    devices: List<PairedDevice>,305    revocations: List<DeviceRevocation>,306    deviceId: String,307    revokedByDeviceId: String,308    nowMs: Long,309): DeviceRevocationMutation {310    val remaining = devices.filterNot { it.deviceId == deviceId }311    if (revocations.any { it.deviceId == deviceId }) {312        return DeviceRevocationMutation(remaining, revocations, null)313    }314    val device = devices.firstOrNull { it.deviceId == deviceId }315    val record = DeviceRevocation(316        deviceId = deviceId,317        longTermPublicKeyHex = device?.longTermPublicKeyHex ?: "0".repeat(64),318        revokedAt = nowMs,319        revokedByDeviceId = revokedByDeviceId,320    )321    val retained = if (revocations.size < MAX_REVOCATIONS) {322        revocations323    } else {324        val removable = revocations.indexOfFirst { it.revokedByDeviceId != revokedByDeviceId }325        if (removable >= 0) revocations.toMutableList().also { it.removeAt(removable) } else revocations326    }327    return DeviceRevocationMutation(remaining, (retained + record).takeLast(MAX_REVOCATIONS), record)328}329330/**331 * The mesh relay is pushed out through the same gate that decides whether to332 * dial one: a relay this device refuses to use must not be handed to peers333 * either — pushing Peach's own URL out under a "Private relay" label would tell334 * the same lie one layer down.335 */336internal fun withRelaySetting(337    devices: List<PairedDevice>,338    mode: SyncMode,339    storedRelayUrl: String,340): List<PairedDevice> = when (val decision = relayInForce(mode, storedRelayUrl)) {341    is RelayDecision.Dial -> devices.map { it.copy(relayUrl = decision.relayUrl) }342    is RelayDecision.Refuse -> devices343}