PairedDeviceStore.kt

10.2 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        val next = getAll().filterNot { it.deviceId == device.deviceId } + device71        persist(next)72    }7374    override fun remove(deviceId: String) {75        persist(getAll().filterNot { it.deviceId == deviceId })76    }7778    // ── Device revocation (Part 5 §C) ────────────────────────────────────────7980    override fun listRevocations(): List<DeviceRevocation> {81        val raw = prefs.getString(KEY_REVOCATIONS, null) ?: return emptyList()82        return runCatching { json.decodeFromString(revocationSerializer, raw) }83            .getOrDefault(emptyList())84    }8586    /**87     * Revoke a device: record it, then drop it from the registry. The record88     * rides out in every manifest this device signs from now on. A stale entry89     * may survive in the pending-vouch queue; the confirm path re-checks90     * revocation before promoting anything out of it.91     */92    fun revoke(93        deviceId: String,94        revokedByDeviceId: String,95        nowMs: Long = System.currentTimeMillis(),96    ): DeviceRevocation? {97        val currentDevices = getAll()98        val currentRevocations = listRevocations()99        val mutation = planDeviceRevocation(100            devices = currentDevices,101            revocations = currentRevocations,102            deviceId = deviceId,103            revokedByDeviceId = revokedByDeviceId,104            nowMs = nowMs,105        )106        if (mutation.revocations !== currentRevocations) {107            persistRevocations(mutation.revocations)108        }109        if (mutation.devices != currentDevices) {110            persist(mutation.devices)111        }112        return mutation.added113    }114115    /**116     * `revokedAt` is peer-supplied and is NOT used for ordering — revocation is117     * terminal, so an incoming record can never displace one already held.118     */119    override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {120        val existing = listRevocations()121        val known = existing.map { it.deviceId }.toSet()122        val fresh = incoming.filterNot { known.contains(it.deviceId) }123        if (fresh.isEmpty()) return emptyList()124        persistRevocations((existing + fresh).takeLast(MAX_REVOCATIONS))125        return fresh.map { it.deviceId }126    }127128    /**129     * Clear a revocation. The ONLY legitimate caller is a direct, SAS-confirmed130     * re-pair — a physically present user on both devices. Never call this from131     * a path a remote peer can reach.132     */133    override fun clearRevocation(deviceId: String) {134        val existing = listRevocations()135        val filtered = existing.filterNot { it.deviceId == deviceId }136        if (filtered.size != existing.size) persistRevocations(filtered)137    }138139    fun clear() {140        prefs.edit().remove(KEY_DEVICES).apply()141    }142143    /**144     * Refresh the device's `lastKnownIp` (the closest real analogue to the145     * brief's `lastSeen`). No-op when the id is unknown or [ip] is null.146     */147    fun updateLastSeen(id: String, ip: String? = null) {148        val current = get(id) ?: return149        if (ip == null) return150        upsert(current.copy(lastKnownIp = ip))151    }152153    fun updateLastSync(id: String, at: Long = System.currentTimeMillis()) {154        val current = get(id) ?: return155        upsert(current.copy(lastSyncedAt = at))156    }157158    /**159     * Reconnect prefers each pair's relay over the global fallback, so a relay160     * change in Settings only reaches established pairs by being written into161     * them. Skips the write when nothing moved, so the observers behind the162     * device list do not churn on a refused change.163     */164    fun propagateRelaySetting(mode: SyncMode, storedRelayUrl: String) {165        val current = getAll()166        val next = withRelaySetting(current, mode, storedRelayUrl)167        if (next != current) persist(next)168    }169170    /**171     * Observe the full list. EncryptedSharedPreferences supports172     * OnSharedPreferenceChangeListener, so this emits on every persist —173     * including writes from other components in-process.174     */175    fun observeAll(): Flow<List<PairedDevice>> = callbackFlow {176        trySend(getAll())177        val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->178            if (key == KEY_DEVICES || key == null) trySend(getAll())179        }180        prefs.registerOnSharedPreferenceChangeListener(listener)181        awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }182    }183184    private fun persistRevocations(records: List<DeviceRevocation>) {185        prefs.edit()186            .putString(KEY_REVOCATIONS, json.encodeToString(revocationSerializer, records))187            .apply()188    }189190    private fun persist(devices: List<PairedDevice>) {191        prefs.edit()192            .putString(KEY_DEVICES, json.encodeToString(listSerializer, devices))193            .apply()194    }195196    private companion object {197        const val PREFS_FILE = "peach_paired_devices"198        const val KEY_DEVICES = "paired_devices"199        const val KEY_REVOCATIONS = "device_revocations"200        /** Bound the list so a hostile peer cannot grow storage without limit. */201        const val MAX_REVOCATIONS = 500202        val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }203    }204}205206internal data class DeviceRevocationMutation(207    val devices: List<PairedDevice>,208    val revocations: List<DeviceRevocation>,209    val added: DeviceRevocation?,210)211212/**213 * Compute one revoke transaction without Android storage so the impossible214 * "paired and already revoked" state can be regression-tested directly.215 *216 * Older builds could leave that overlap behind. A repeated Remove must still217 * drop the live row even though the terminal record itself is idempotent.218 */219internal fun planDeviceRevocation(220    devices: List<PairedDevice>,221    revocations: List<DeviceRevocation>,222    deviceId: String,223    revokedByDeviceId: String,224    nowMs: Long,225): DeviceRevocationMutation {226    val remaining = devices.filterNot { it.deviceId == deviceId }227    if (revocations.any { it.deviceId == deviceId }) {228        return DeviceRevocationMutation(remaining, revocations, null)229    }230    val device = devices.firstOrNull { it.deviceId == deviceId }231    val record = DeviceRevocation(232        deviceId = deviceId,233        longTermPublicKeyHex = device?.longTermPublicKeyHex ?: "0".repeat(64),234        revokedAt = nowMs,235        revokedByDeviceId = revokedByDeviceId,236    )237    return DeviceRevocationMutation(remaining, revocations + record, record)238}239240/**241 * The mesh relay is pushed out through the same gate that decides whether to242 * dial one: a relay this device refuses to use must not be handed to peers243 * either — pushing Peach's own URL out under a "Private relay" label would tell244 * the same lie one layer down.245 */246internal fun withRelaySetting(247    devices: List<PairedDevice>,248    mode: SyncMode,249    storedRelayUrl: String,250): List<PairedDevice> = when (val decision = relayInForce(mode, storedRelayUrl)) {251    is RelayDecision.Dial -> devices.map { it.copy(relayUrl = decision.relayUrl) }252    is RelayDecision.Refuse -> devices253}