PairedDeviceStore.kt

24.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 java.util.concurrent.locks.ReentrantLock13import javax.inject.Inject14import javax.inject.Singleton15import kotlin.concurrent.withLock1617private const val MAX_REVOCATION_RECORDS = 5001819/**20 * Persistent registry of paired devices, encrypted at rest with21 * EncryptedSharedPreferences (same construction as DuressConfigStore /22 * LicenseRepository, v2.1.36). The whole list lives under a single key as a23 * kotlinx.serialization JSON array of the REAL [PairedDevice] (sync package).24 *25 * Reconciliation with the C7 brief:26 *  - The brief invents a fresh `PairedDevice(id, name, publicKey: ByteArray,27 *    lastSeen, trustStatus, lastSyncAt)`. That type does NOT exist in this28 *    shape. The real [PairedDevice] (PairedDevice.kt) keys on `deviceId`, holds29 *    the public key as `longTermPublicKeyHex` (hex, not ByteArray), and tracks30 *    `pairedAt` / `lastSyncedAt` / `lastKnownIp`. This store reuses it verbatim;31 *    Base64 publicKey storage is therefore unnecessary — the hex field is the32 *    canonical wire form already produced by PairingFrames.frameToPairedDevice.33 *  - The brief's `TrustStatus` enum does not exist either. The real model uses a34 *    nullable `trustSource: String?` (e.g. "direct", "manifest"). Trust state is35 *    expressed there. See C7-INTEGRATION.md for the trust-status delta.36 *  - `updateLastSync` here maps to the real `lastSyncedAt` field. There is no37 *    `lastSeen` field on the real PairedDevice; [updateLastSeen] folds into38 *    `lastKnownIp` refresh + `lastSyncedAt` is left untouched. It is kept as a39 *    no-op-friendly hook so callers from the brief compile; see integration note.40 */41@Singleton42class PairedDeviceStore @Inject constructor(43    @ApplicationContext context: Context,44) : PairedDeviceRegistry {45    private val masterKey = MasterKey.Builder(context)46        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)47        .build()4849    private val prefs = EncryptedSharedPreferences.create(50        context,51        PREFS_FILE,52        masterKey,53        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,54        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,55    )5657    private val listSerializer = ListSerializer(PairedDevice.serializer())58    private val revocationSerializer = ListSerializer(DeviceRevocation.serializer())59    private val mutationGate = RegistryMutationGate()6061    /** Set by [revoke]; see [protectedRevocationIssuers]. */62    @Volatile private var knownLocalDeviceId: String? = null6364    override fun getAll(): List<PairedDevice> {65        val raw = prefs.getString(KEY_DEVICES, null) ?: return emptyList()66        return try {67            json.decodeFromString(listSerializer, raw)68        } catch (e: Exception) {69            throw TrustDataCorruptedException(KEY_DEVICES, e.message ?: "decode_failed")70        }71    }7273    override fun get(id: String): PairedDevice? = getAll().firstOrNull { it.deviceId == id }7475    override fun getByPubkey(pubkeyHex: String): PairedDevice? =76        getAll().firstOrNull {77            it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true78        }7980    fun hasIdentityCollision(device: PairedDevice): Boolean =81        hasAdoptionIdentityConflict(device, getAll(), listRevocations())8283    /** Insert or replace a directly re-paired device and clear its terminal revocation atomically. */84    fun upsertAndClearRevocation(device: PairedDevice) {85        mutationGate.withLock {86            val currentDevices = getAll()87            val existing = currentDevices.firstOrNull { it.deviceId == device.deviceId }88            val bound = bindImmutableIdentity(existing, device)89            val nextDevice = bound.copy(90                deviceName = bound.deviceName?.let(::capDeviceName),91                localAlias = existing?.localAlias,92            )93            val nextDevices = currentDevices.filterNot { it.deviceId == device.deviceId } + nextDevice94            val currentRevocations = listRevocations()95            val nextRevocations = currentRevocations.filterNot { it.deviceId == device.deviceId }96            val nextPrefs = RegistryPrefsState(97                devices = json.encodeToString(listSerializer, nextDevices),98                revocations = json.encodeToString(revocationSerializer, nextRevocations),99            )100            check(commitWithRollback(readPrefsState(), nextPrefs, ::writePrefsState)) {101                "paired_device_persist_failed"102            }103        }104    }105106    /** Insert or replace by [PairedDevice.deviceId], without changing its cryptographic identity. */107    override fun upsert(device: PairedDevice) {108        mutationGate.withLock {109            val currentDevices = getAll()110            val existing = currentDevices.firstOrNull { it.deviceId == device.deviceId }111            val bound = bindImmutableIdentity(existing, device)112            // Peer-supplied names arrive capped so a hostile peer cannot grow the113            // registry without limit (adoption + direct-pair seams land here).114            val nextDevice = bound.copy(115                deviceName = bound.deviceName?.let(::capDeviceName),116                localAlias = existing?.localAlias,117            )118            val next = currentDevices.filterNot { it.deviceId == device.deviceId } + nextDevice119            persist(next)120        }121    }122123    /**124     * Commit a staged manifest in one synchronous SharedPreferences transaction.125     * `apply()` is appropriate for ordinary UI writes, but it would expose each126     * per-device edit independently to observers and could leave an old-session127     * manifest half-applied across a lock. `commit()` updates both keys together128     * in memory and does not return until the durable write has completed.129     */130    override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean {131        return mutationGate.withLock {132            val priorPrefs = readPrefsState()133            val currentDevices = getAll()134            val currentById = currentDevices.associateBy { it.deviceId }135            val currentRevocations = listRevocations()136            val nextRevocations = mergeRevocationRecords(currentRevocations, batch.incomingRevocations).records137            val removed = batch.removals138            val nextDevices = currentDevices139                .filterNot { removed.contains(it.deviceId) }140                .toMutableList()141            batch.upserts.forEach { incoming ->142                val existing = currentById[incoming.deviceId]143                    ?: nextDevices.firstOrNull { it.deviceId == incoming.deviceId }144                require(!nextDevices.any { candidate ->145                    candidate.deviceId != incoming.deviceId &&146                        ((candidate.longTermPublicKeyHex != null && incoming.longTermPublicKeyHex != null && CryptoCompare.hex(candidate.longTermPublicKeyHex, incoming.longTermPublicKeyHex)) ||147                            (candidate.signingPublicKeyHex != null && incoming.signingPublicKeyHex != null && CryptoCompare.hex(candidate.signingPublicKeyHex, incoming.signingPublicKeyHex)))148                }) { "device_identity_collision" }149                val bound = bindImmutableIdentity(existing, incoming)150                val alias = nextDevices.firstOrNull { it.deviceId == incoming.deviceId }?.localAlias151                val device = bound.copy(152                    deviceName = bound.deviceName?.let(::capDeviceName),153                    localAlias = alias,154                )155                nextDevices.removeAll { it.deviceId == device.deviceId }156                nextDevices += device157            }158159            val nextPrefs = RegistryPrefsState(160                devices = json.encodeToString(listSerializer, nextDevices),161                revocations = json.encodeToString(revocationSerializer, nextRevocations),162            )163            // Explicitly restore the old encrypted-preference values when commit()164            // reports false. The platform usually keeps them in memory, but a real165            // store failure must not rely on that undocumented outcome.166            commitWithRollback(priorPrefs, nextPrefs, ::writePrefsState)167        }168    }169170    fun rename(deviceId: String, name: String) {171        mutationGate.withLock {172            persist(withLocalAlias(getAll(), deviceId, name))173        }174    }175176    override fun remove(deviceId: String) {177        mutationGate.withLock {178            persist(getAll().filterNot { it.deviceId == deviceId })179        }180    }181182183184    // ── Device revocation (Part 5 §C) ────────────────────────────────────────185186    override fun listRevocations(): List<DeviceRevocation> {187        val raw = prefs.getString(KEY_REVOCATIONS, null) ?: return emptyList()188        return try {189            json.decodeFromString(revocationSerializer, raw)190        } catch (e: Exception) {191            throw TrustDataCorruptedException(KEY_REVOCATIONS, e.message ?: "decode_failed")192        }193    }194195    /**196     * Revoke a device: record it, then drop it from the registry. The record197     * rides out in every manifest this device signs from now on. A stale entry198     * may survive in the pending-vouch queue; the confirm path re-checks199     * revocation before promoting anything out of it.200     */201    fun revoke(202        deviceId: String,203        revokedByDeviceId: String,204        nowMs: Long = System.currentTimeMillis(),205    ): DeviceRevocation? = mutationGate.withLock {206        knownLocalDeviceId = revokedByDeviceId207        val currentDevices = getAll()208        val currentRevocations = listRevocations()209        val mutation = planDeviceRevocation(210            devices = currentDevices,211            revocations = currentRevocations,212            deviceId = deviceId,213            revokedByDeviceId = revokedByDeviceId,214            nowMs = nowMs,215            protectedIssuerIds = protectedRevocationIssuers(),216        )217        // One verified transaction for both keys. Two independent apply() calls218        // can land separately, and a device removed without its revocation219        // record stops being blocked from re-admission — a silent failure of the220        // ejection path rather than a visible error.221        val nextPrefs = RegistryPrefsState(222            devices = json.encodeToString(listSerializer, mutation.devices),223            revocations = json.encodeToString(revocationSerializer, mutation.revocations),224        )225        check(commitWithRollback(readPrefsState(), nextPrefs, ::writePrefsState)) {226            "paired_device_persist_failed"227        }228        mutation.added229    }230231    /**232     * `revokedAt` is peer-supplied and is NOT used for ordering. Existing233     * terminal records remain authoritative; only unprotected relayed records234     * may be evicted when the bounded ledger is full.235     */236    override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {237        return mutationGate.withLock {238            val existing = listRevocations()239            val merged = mergeRevocationRecords(existing, incoming, protectedRevocationIssuers())240            if (merged.records != existing) persistRevocations(merged.records)241            merged.admitted.map { it.deviceId }242        }243    }244245    /**246     * Revocations this device issued, and those issued by a direct peer it247     * SAS-confirmed, are terminal: evicting one to make room would re-admit a248     * device the ledger exists to block (PB-26-130). Only relayed/indirect249     * records may be pruned when the ledger is full.250     *251     * The local device id is learned from [revoke] rather than loaded here: the252     * identity loader can mint, and a trust-decision path must not have that253     * side effect.254     */255    private fun protectedRevocationIssuers(): Set<String> = buildSet {256        knownLocalDeviceId?.let(::add)257        getAll().filter { it.trustSource == "direct" }.forEach { add(it.deviceId) }258    }259260    /**261     * Clear a revocation. The ONLY legitimate caller is a direct, SAS-confirmed262     * re-pair — a physically present user on both devices. Never call this from263     * a path a remote peer can reach.264     */265    override fun clearRevocation(deviceId: String) {266        mutationGate.withLock {267            val existing = listRevocations()268            val filtered = existing.filterNot { it.deviceId == deviceId }269            if (filtered.size != existing.size) persistRevocations(filtered)270        }271    }272273    fun clear() {274        mutationGate.withLock {275            val edit = prefs.edit()276                .remove(KEY_DEVICES)277                .remove(KEY_REVOCATIONS)278            // Known-key history is trust state: leaving it behind would keep a279            // cleared installation admitting keys it no longer holds devices280            // for, so it goes with the rest of the registry.281            for (key in prefs.all.keys.filter { it.startsWith(KEY_ISSUER_KEYS_PREFIX) }) {282                edit.remove(key)283            }284            edit.commit()285        }286    }287288    /** Verbatim logical copy of the whole registry, for the secure-reset archive. */289    internal fun snapshot(): Map<String, Any?> = prefs.all290291    /**292     * Refresh the device's `lastKnownIp` (the closest real analogue to the293     * brief's `lastSeen`). No-op when the id is unknown or [ip] is null.294     */295    fun updateLastSeen(id: String, ip: String? = null) {296        mutationGate.withLock {297            val current = get(id) ?: return@withLock298            if (ip == null) return@withLock299            upsert(current.copy(lastKnownIp = ip))300        }301    }302303    fun updateLastSync(id: String, at: Long = System.currentTimeMillis()) {304        mutationGate.withLock {305            val current = get(id) ?: return@withLock306            upsert(current.copy(lastSyncedAt = at))307        }308    }309310    /**311     * Reconnect prefers each pair's relay over the global fallback, so a relay312     * change in Settings only reaches established pairs by being written into313     * them. Skips the write when nothing moved, so the observers behind the314     * device list do not churn on a refused change.315     */316    fun propagateRelaySetting(mode: SyncMode, storedRelayUrl: String) {317        mutationGate.withLock {318            val current = getAll()319            val next = withRelaySetting(current, mode, storedRelayUrl)320            if (next != current) persist(next)321        }322    }323324    /**325     * Observe the full list. EncryptedSharedPreferences supports326     * OnSharedPreferenceChangeListener, so this emits on every persist —327     * including writes from other components in-process.328     */329    fun observeAll(): Flow<List<PairedDevice>> = callbackFlow {330        trySend(getAll())331        val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->332            if (key == KEY_DEVICES || key == null) trySend(getAll())333        }334        prefs.registerOnSharedPreferenceChangeListener(listener)335        awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }336    }337338    override fun issuerSigningKeys(issuerDeviceId: String): Set<String> =339        prefs.getStringSet("$KEY_ISSUER_KEYS_PREFIX$issuerDeviceId", emptySet())340            .orEmpty().map(String::lowercase).toSet()341342    /** Any known-key history at all: legacy trust state for the reset probe. */343    fun hasIssuerSigningKeys(): Boolean = prefs.all.keys.any { it.startsWith(KEY_ISSUER_KEYS_PREFIX) }344345    override fun appendIssuerSigningKey(issuerDeviceId: String, signingKeyHex: String): Boolean {346        val key = signingKeyHex.lowercase()347        if (issuerDeviceId.isBlank() || key.length != 64) return false348        synchronized(mutationGate) {349            val existing = issuerSigningKeys(issuerDeviceId)350            if (key in existing) return true351            return prefs.edit()352                .putStringSet("$KEY_ISSUER_KEYS_PREFIX$issuerDeviceId", existing + key)353                .commit()354        }355    }356357    /**358     * Revocation mutations must be durable and verified. `apply()` returns359     * before the write is on disk, and losing the terminal ledger silently360     * un-revokes devices, so the ledger is never written that way.361     */362    private fun persistRevocations(records: List<DeviceRevocation>) {363        val prior = readPrefsState()364        val next = prior.copy(365            revocations = json.encodeToString(revocationSerializer, records),366        )367        check(commitWithRollback(prior, next, ::writePrefsState)) {368            "paired_device_persist_failed"369        }370    }371372    private fun persist(devices: List<PairedDevice>) {373        prefs.edit()374            .putString(KEY_DEVICES, json.encodeToString(listSerializer, devices))375            .apply()376    }377378    private data class RegistryPrefsState(379        val devices: String?,380        val revocations: String?,381    )382383    private fun readPrefsState(): RegistryPrefsState = RegistryPrefsState(384        devices = prefs.getString(KEY_DEVICES, null),385        revocations = prefs.getString(KEY_REVOCATIONS, null),386    )387388    private fun writePrefsState(state: RegistryPrefsState): Boolean {389        val edit = prefs.edit()390        if (state.devices == null) edit.remove(KEY_DEVICES) else edit.putString(KEY_DEVICES, state.devices)391        if (state.revocations == null) edit.remove(KEY_REVOCATIONS) else edit.putString(KEY_REVOCATIONS, state.revocations)392        return edit.commit()393    }394395    private companion object {396        const val PREFS_FILE = "peach_paired_devices"397        const val KEY_DEVICES = "paired_devices"398        const val KEY_REVOCATIONS = "device_revocations"399        const val KEY_ISSUER_KEYS_PREFIX = "issuer_signing_keys:"400        const val MAX_REVOCATIONS = MAX_REVOCATION_RECORDS401        val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }402    }403}404405/**406 * §18.4: a corrupt persisted registry or revocation ledger is an integrity407 * error, not an empty list. Silently returning empty un-revokes every device.408 * The corrupt payload is preserved (the store never writes on this path) so a409 * future repair can attempt recovery.410 */411class TrustDataCorruptedException(key: String, detail: String) :412    RuntimeException("trust_data_corrupted:$key:$detail")413/** Preserve a persisted device's cryptographic identity across metadata updates. */414internal fun bindImmutableIdentity(existing: PairedDevice?, incoming: PairedDevice): PairedDevice {415    if (existing == null) return incoming416417    val longTermChanged = existing.longTermPublicKeyHex != null &&418        incoming.longTermPublicKeyHex != null &&419        !CryptoCompare.hex(existing.longTermPublicKeyHex, incoming.longTermPublicKeyHex)420    val signingChanged = existing.signingPublicKeyHex != null &&421        incoming.signingPublicKeyHex != null &&422        !CryptoCompare.hex(existing.signingPublicKeyHex, incoming.signingPublicKeyHex)423    require(!longTermChanged && !signingChanged) { "device_identity_immutable" }424425    return incoming.copy(426        longTermPublicKeyHex = incoming.longTermPublicKeyHex ?: existing.longTermPublicKeyHex,427        signingPublicKeyHex = incoming.signingPublicKeyHex ?: existing.signingPublicKeyHex,428    )429}430431/** Serialises whole-list registry mutations so stale snapshots cannot win. */432internal class RegistryMutationGate {433    private val lock = ReentrantLock()434435    fun <T> withLock(block: () -> T): T = lock.withLock(block)436}437438internal fun withLocalAlias(439    devices: List<PairedDevice>,440    deviceId: String,441    name: String,442): List<PairedDevice> {443    val alias = name.trim()444    require(alias.isNotEmpty()) { "device name must not be empty" }445    require(alias.length <= MAX_DEVICE_NAME_CHARS) { "device name must be 64 chars or fewer" }446    require(devices.any { it.deviceId == deviceId }) { "device_not_found" }447    return devices.map { if (it.deviceId == deviceId) it.copy(localAlias = alias) else it }448}449450internal data class DeviceRevocationMutation(451    val devices: List<PairedDevice>,452    val revocations: List<DeviceRevocation>,453    val added: DeviceRevocation?,454)455456/** A fresh pairing ceremony must not reuse terminally revoked identity material. */457internal fun hasAdoptionIdentityConflict(458    incoming: PairedDevice,459    devices: List<PairedDevice>,460    revocations: List<DeviceRevocation>,461): Boolean {462    if (devices.any { it.deviceId == incoming.deviceId }) return true463    if (devices.any {464        it.longTermPublicKeyHex?.let { key -> incoming.longTermPublicKeyHex?.let { candidate -> CryptoCompare.hex(key, candidate) } } == true ||465            it.signingPublicKeyHex?.let { key -> incoming.signingPublicKeyHex?.let { candidate -> CryptoCompare.hex(key, candidate) } } == true466    }) return true467    return revocations.any { revoked ->468        revoked.deviceId == incoming.deviceId ||469            incoming.longTermPublicKeyHex?.let { CryptoCompare.hex(revoked.longTermPublicKeyHex, it) } == true470    }471}472473/**474 * Compute one revoke transaction without Android storage so the impossible475 * "paired and already revoked" state can be regression-tested directly.476 *477 * Older builds could leave that overlap behind. A repeated Remove must still478 * drop the live row even though the terminal record itself is idempotent.479 */480internal fun planDeviceRevocation(481    devices: List<PairedDevice>,482    revocations: List<DeviceRevocation>,483    deviceId: String,484    revokedByDeviceId: String,485    nowMs: Long,486    protectedIssuerIds: Set<String> = emptySet(),487): DeviceRevocationMutation {488    val remaining = devices.filterNot { it.deviceId == deviceId }489    if (revocations.any { it.deviceId == deviceId }) {490        return DeviceRevocationMutation(remaining, revocations, null)491    }492    val device = devices.firstOrNull { it.deviceId == deviceId }493    val record = DeviceRevocation(494        deviceId = deviceId,495        longTermPublicKeyHex = device?.longTermPublicKeyHex ?: "0".repeat(64),496        revokedAt = nowMs,497        revokedByDeviceId = revokedByDeviceId,498    )499    return DeviceRevocationMutation(500        remaining,501        capRevocationRecords(revocations + record, protectedIssuerIds).records,502        record,503    )504}505internal data class RevocationMergeResult(506    val records: List<DeviceRevocation>,507    val admitted: List<DeviceRevocation>,508    val outcome: RevocationMergeOutcome = RevocationMergeOutcome.MERGED,509)510511internal enum class RevocationMergeOutcome {512    MERGED,513    OVERFLOW_PROTECTED_LEDGER,514}515516internal data class RevocationCapResult(517    val records: List<DeviceRevocation>,518    val outcome: RevocationMergeOutcome,519)520521internal fun mergeRevocationRecords(522    existing: List<DeviceRevocation>,523    incoming: List<DeviceRevocation>,524    protectedIssuerIds: Set<String> = emptySet(),525): RevocationMergeResult {526    val known = existing.map { it.deviceId }.toSet()527    val fresh = incoming.filterNot { known.contains(it.deviceId) }528    val capped = capRevocationRecords(existing + fresh, protectedIssuerIds)529    if (capped.outcome == RevocationMergeOutcome.OVERFLOW_PROTECTED_LEDGER) {530        return RevocationMergeResult(existing, emptyList(), capped.outcome)531    }532    val admitted = fresh.filter { candidate -> capped.records.any { it.deviceId == candidate.deviceId } }533    return RevocationMergeResult(capped.records, admitted, capped.outcome)534}535536/**537 * Keep the terminal ledger bounded. Records issued by this device or a direct538 * peer are protected; only relayed/indirect issuers are eviction candidates.539 * If all candidates are protected, refuse the new records rather than dropping540 * trusted state. The overflow outcome is explicit in the cap result.541 */542internal fun capRevocationRecords(543    records: List<DeviceRevocation>,544    protectedIssuerIds: Set<String> = emptySet(),545): RevocationCapResult {546    if (records.size <= MAX_REVOCATION_RECORDS) {547        return RevocationCapResult(records, RevocationMergeOutcome.MERGED)548    }549    val candidates = records.indices.filter { records[it].revokedByDeviceId !in protectedIssuerIds }550    if (candidates.size < records.size - MAX_REVOCATION_RECORDS) {551        return RevocationCapResult(records, RevocationMergeOutcome.OVERFLOW_PROTECTED_LEDGER)552    }553    val dropped = candidates.take(records.size - MAX_REVOCATION_RECORDS).toSet()554    return RevocationCapResult(555        records.filterIndexed { index, _ -> index !in dropped },556        RevocationMergeOutcome.MERGED,557    )558}559560/**561 * The mesh relay is pushed out through the same gate that decides whether to562 * dial one: a relay this device refuses to use must not be handed to peers563 * either — pushing Peach's own URL out under a "Private relay" label would tell564 * the same lie one layer down.565 */566internal fun withRelaySetting(567    devices: List<PairedDevice>,568    mode: SyncMode,569    storedRelayUrl: String,570): List<PairedDevice> = when (val decision = relayInForce(mode, storedRelayUrl)) {571    is RelayDecision.Dial -> devices.map { it.copy(relayUrl = decision.relayUrl) }572    is RelayDecision.Refuse -> devices573}