PairedDeviceStore.kt

13.8 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.Singleton1415/**16 * Persistent registry of paired devices, encrypted at rest with17 * EncryptedSharedPreferences (same construction as DuressConfigStore /18 * LicenseRepository, v2.1.36). The whole list lives under a single key as a19 * kotlinx.serialization JSON array of the REAL [PairedDevice] (sync package).20 *21 * Reconciliation with the C7 brief:22 *  - The brief invents a fresh `PairedDevice(id, name, publicKey: ByteArray,23 *    lastSeen, trustStatus, lastSyncAt)`. That type does NOT exist in this24 *    shape. The real [PairedDevice] (PairedDevice.kt) keys on `deviceId`, holds25 *    the public key as `longTermPublicKeyHex` (hex, not ByteArray), and tracks26 *    `pairedAt` / `lastSyncedAt` / `lastKnownIp`. This store reuses it verbatim;27 *    Base64 publicKey storage is therefore unnecessary — the hex field is the28 *    canonical wire form already produced by PairingFrames.frameToPairedDevice.29 *  - The brief's `TrustStatus` enum does not exist either. The real model uses a30 *    nullable `trustSource: String?` (e.g. "direct", "manifest"). Trust state is31 *    expressed there. See C7-INTEGRATION.md for the trust-status delta.32 *  - `updateLastSync` here maps to the real `lastSyncedAt` field. There is no33 *    `lastSeen` field on the real PairedDevice; [updateLastSeen] folds into34 *    `lastKnownIp` refresh + `lastSyncedAt` is left untouched. It is kept as a35 *    no-op-friendly hook so callers from the brief compile; see integration note.36 */37@Singleton38class PairedDeviceStore @Inject constructor(39    @ApplicationContext context: Context,40) : PairedDeviceRegistry {41    private val masterKey = MasterKey.Builder(context)42        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)43        .build()4445    private val prefs = EncryptedSharedPreferences.create(46        context,47        PREFS_FILE,48        masterKey,49        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,50        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,51    )5253    private val listSerializer = ListSerializer(PairedDevice.serializer())54    private val revocationSerializer = ListSerializer(DeviceRevocation.serializer())5556    override fun getAll(): List<PairedDevice> {57        val raw = prefs.getString(KEY_DEVICES, null) ?: return emptyList()58        return runCatching { json.decodeFromString(listSerializer, raw) }.getOrDefault(emptyList())59    }6061    override fun get(id: String): PairedDevice? = getAll().firstOrNull { it.deviceId == id }6263    override fun getByPubkey(pubkeyHex: String): PairedDevice? =64        getAll().firstOrNull {65            it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true66        }6768    /** Insert or replace by [PairedDevice.deviceId]. */69    override fun upsert(device: PairedDevice) {70        // Peer-supplied names arrive capped so a hostile peer cannot grow the71        // registry without limit (adoption + direct-pair seams land here).72        val device = device.copy(73            deviceName = device.deviceName?.let(::capDeviceName),74            localAlias = get(device.deviceId)?.localAlias,75        )76        val next = getAll().filterNot { it.deviceId == device.deviceId } + device77        persist(next)78    }7980    fun rename(deviceId: String, name: String) {81        persist(withLocalAlias(getAll(), deviceId, name))82    }8384    override fun remove(deviceId: String) {85        persist(getAll().filterNot { it.deviceId == deviceId })86    }8788    /**89     * Commit a staged manifest in one synchronous SharedPreferences transaction.90     * `apply()` is appropriate for ordinary UI writes, but it would expose each91     * per-device edit independently to observers and could leave an old-session92     * manifest half-applied across a lock. `commit()` updates both keys together93     * in memory and does not return until the durable write has completed.94     */95    override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean {96        val priorPrefs = readPrefsState()97        val currentDevices = getAll()98        val currentRevocations = listRevocations()99        val knownRevokedIds = currentRevocations.map { it.deviceId }.toSet()100        val freshRevocations = batch.incomingRevocations.filterNot { knownRevokedIds.contains(it.deviceId) }101        val nextRevocations = (currentRevocations + freshRevocations).takeLast(MAX_REVOCATIONS)102        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 known = existing.map { it.deviceId }.toSet()170        val fresh = incoming.filterNot { known.contains(it.deviceId) }171        if (fresh.isEmpty()) return emptyList()172        persistRevocations((existing + fresh).takeLast(MAX_REVOCATIONS))173        return fresh.map { it.deviceId }174    }175176    /**177     * Clear a revocation. The ONLY legitimate caller is a direct, SAS-confirmed178     * re-pair — a physically present user on both devices. Never call this from179     * a path a remote peer can reach.180     */181    override fun clearRevocation(deviceId: String) {182        val existing = listRevocations()183        val filtered = existing.filterNot { it.deviceId == deviceId }184        if (filtered.size != existing.size) persistRevocations(filtered)185    }186187    fun clear() {188        prefs.edit()189            .remove(KEY_DEVICES)190            .remove(KEY_REVOCATIONS)191            .apply()192    }193194    /**195     * Refresh the device's `lastKnownIp` (the closest real analogue to the196     * brief's `lastSeen`). No-op when the id is unknown or [ip] is null.197     */198    fun updateLastSeen(id: String, ip: String? = null) {199        val current = get(id) ?: return200        if (ip == null) return201        upsert(current.copy(lastKnownIp = ip))202    }203204    fun updateLastSync(id: String, at: Long = System.currentTimeMillis()) {205        val current = get(id) ?: return206        upsert(current.copy(lastSyncedAt = at))207    }208209    /**210     * Reconnect prefers each pair's relay over the global fallback, so a relay211     * change in Settings only reaches established pairs by being written into212     * them. Skips the write when nothing moved, so the observers behind the213     * device list do not churn on a refused change.214     */215    fun propagateRelaySetting(mode: SyncMode, storedRelayUrl: String) {216        val current = getAll()217        val next = withRelaySetting(current, mode, storedRelayUrl)218        if (next != current) persist(next)219    }220221    /**222     * Observe the full list. EncryptedSharedPreferences supports223     * OnSharedPreferenceChangeListener, so this emits on every persist —224     * including writes from other components in-process.225     */226    fun observeAll(): Flow<List<PairedDevice>> = callbackFlow {227        trySend(getAll())228        val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->229            if (key == KEY_DEVICES || key == null) trySend(getAll())230        }231        prefs.registerOnSharedPreferenceChangeListener(listener)232        awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }233    }234235    private fun persistRevocations(records: List<DeviceRevocation>) {236        prefs.edit()237            .putString(KEY_REVOCATIONS, json.encodeToString(revocationSerializer, records))238            .apply()239    }240241    private fun persist(devices: List<PairedDevice>) {242        prefs.edit()243            .putString(KEY_DEVICES, json.encodeToString(listSerializer, devices))244            .apply()245    }246247    private data class RegistryPrefsState(248        val devices: String?,249        val revocations: String?,250    )251252    private fun readPrefsState(): RegistryPrefsState = RegistryPrefsState(253        devices = prefs.getString(KEY_DEVICES, null),254        revocations = prefs.getString(KEY_REVOCATIONS, null),255    )256257    private fun writePrefsState(state: RegistryPrefsState): Boolean {258        val edit = prefs.edit()259        if (state.devices == null) edit.remove(KEY_DEVICES) else edit.putString(KEY_DEVICES, state.devices)260        if (state.revocations == null) edit.remove(KEY_REVOCATIONS) else edit.putString(KEY_REVOCATIONS, state.revocations)261        return edit.commit()262    }263264    private companion object {265        const val PREFS_FILE = "peach_paired_devices"266        const val KEY_DEVICES = "paired_devices"267        const val KEY_REVOCATIONS = "device_revocations"268        /** Bound the list so a hostile peer cannot grow storage without limit. */269        const val MAX_REVOCATIONS = 500270        val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }271    }272}273274internal fun withLocalAlias(275    devices: List<PairedDevice>,276    deviceId: String,277    name: String,278): List<PairedDevice> {279    val alias = name.trim()280    require(alias.isNotEmpty()) { "device name must not be empty" }281    require(alias.length <= MAX_DEVICE_NAME_CHARS) { "device name must be 64 chars or fewer" }282    require(devices.any { it.deviceId == deviceId }) { "device_not_found" }283    return devices.map { if (it.deviceId == deviceId) it.copy(localAlias = alias) else it }284}285286internal data class DeviceRevocationMutation(287    val devices: List<PairedDevice>,288    val revocations: List<DeviceRevocation>,289    val added: DeviceRevocation?,290)291292/**293 * Compute one revoke transaction without Android storage so the impossible294 * "paired and already revoked" state can be regression-tested directly.295 *296 * Older builds could leave that overlap behind. A repeated Remove must still297 * drop the live row even though the terminal record itself is idempotent.298 */299internal fun planDeviceRevocation(300    devices: List<PairedDevice>,301    revocations: List<DeviceRevocation>,302    deviceId: String,303    revokedByDeviceId: String,304    nowMs: Long,305): DeviceRevocationMutation {306    val remaining = devices.filterNot { it.deviceId == deviceId }307    if (revocations.any { it.deviceId == deviceId }) {308        return DeviceRevocationMutation(remaining, revocations, null)309    }310    val device = devices.firstOrNull { it.deviceId == deviceId }311    val record = DeviceRevocation(312        deviceId = deviceId,313        longTermPublicKeyHex = device?.longTermPublicKeyHex ?: "0".repeat(64),314        revokedAt = nowMs,315        revokedByDeviceId = revokedByDeviceId,316    )317    return DeviceRevocationMutation(remaining, revocations + record, record)318}319320/**321 * The mesh relay is pushed out through the same gate that decides whether to322 * dial one: a relay this device refuses to use must not be handed to peers323 * either — pushing Peach's own URL out under a "Private relay" label would tell324 * the same lie one layer down.325 */326internal fun withRelaySetting(327    devices: List<PairedDevice>,328    mode: SyncMode,329    storedRelayUrl: String,330): List<PairedDevice> = when (val decision = relayInForce(mode, storedRelayUrl)) {331    is RelayDecision.Dial -> devices.map { it.copy(relayUrl = decision.relayUrl) }332    is RelayDecision.Refuse -> devices333}