package com.peachpasswords.sync import android.content.Context import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json import java.util.concurrent.locks.ReentrantLock import javax.inject.Inject import javax.inject.Singleton import kotlin.concurrent.withLock private const val MAX_REVOCATION_RECORDS = 500 /** * Persistent registry of paired devices, encrypted at rest with * EncryptedSharedPreferences (same construction as DuressConfigStore / * LicenseRepository, v2.1.36). The whole list lives under a single key as a * kotlinx.serialization JSON array of the REAL [PairedDevice] (sync package). * * Reconciliation with the C7 brief: * - The brief invents a fresh `PairedDevice(id, name, publicKey: ByteArray, * lastSeen, trustStatus, lastSyncAt)`. That type does NOT exist in this * shape. The real [PairedDevice] (PairedDevice.kt) keys on `deviceId`, holds * the public key as `longTermPublicKeyHex` (hex, not ByteArray), and tracks * `pairedAt` / `lastSyncedAt` / `lastKnownIp`. This store reuses it verbatim; * Base64 publicKey storage is therefore unnecessary — the hex field is the * canonical wire form already produced by PairingFrames.frameToPairedDevice. * - The brief's `TrustStatus` enum does not exist either. The real model uses a * nullable `trustSource: String?` (e.g. "direct", "manifest"). Trust state is * expressed there. See C7-INTEGRATION.md for the trust-status delta. * - `updateLastSync` here maps to the real `lastSyncedAt` field. There is no * `lastSeen` field on the real PairedDevice; [updateLastSeen] folds into * `lastKnownIp` refresh + `lastSyncedAt` is left untouched. It is kept as a * no-op-friendly hook so callers from the brief compile; see integration note. */ @Singleton class PairedDeviceStore @Inject constructor( @ApplicationContext context: Context, ) : PairedDeviceRegistry { private val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() private val prefs = EncryptedSharedPreferences.create( context, PREFS_FILE, masterKey, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, ) private val listSerializer = ListSerializer(PairedDevice.serializer()) private val revocationSerializer = ListSerializer(DeviceRevocation.serializer()) private val mutationGate = RegistryMutationGate() /** Set by [revoke]; see [protectedRevocationIssuers]. */ @Volatile private var knownLocalDeviceId: String? = null override fun getAll(): List { val raw = prefs.getString(KEY_DEVICES, null) ?: return emptyList() return try { json.decodeFromString(listSerializer, raw) } catch (e: Exception) { throw TrustDataCorruptedException(KEY_DEVICES, e.message ?: "decode_failed") } } override fun get(id: String): PairedDevice? = getAll().firstOrNull { it.deviceId == id } override fun getByPubkey(pubkeyHex: String): PairedDevice? = getAll().firstOrNull { it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true } fun hasIdentityCollision(device: PairedDevice): Boolean = hasAdoptionIdentityConflict(device, getAll(), listRevocations()) /** Insert or replace a directly re-paired device and clear its terminal revocation atomically. */ fun upsertAndClearRevocation(device: PairedDevice) { mutationGate.withLock { val currentDevices = getAll() val existing = currentDevices.firstOrNull { it.deviceId == device.deviceId } val bound = bindImmutableIdentity(existing, device) val nextDevice = bound.copy( deviceName = bound.deviceName?.let(::capDeviceName), localAlias = existing?.localAlias, ) val nextDevices = currentDevices.filterNot { it.deviceId == device.deviceId } + nextDevice val currentRevocations = listRevocations() val nextRevocations = currentRevocations.filterNot { it.deviceId == device.deviceId } val nextPrefs = RegistryPrefsState( devices = json.encodeToString(listSerializer, nextDevices), revocations = json.encodeToString(revocationSerializer, nextRevocations), ) check(commitWithRollback(readPrefsState(), nextPrefs, ::writePrefsState)) { "paired_device_persist_failed" } } } /** Insert or replace by [PairedDevice.deviceId], without changing its cryptographic identity. */ override fun upsert(device: PairedDevice) { mutationGate.withLock { val currentDevices = getAll() val existing = currentDevices.firstOrNull { it.deviceId == device.deviceId } val bound = bindImmutableIdentity(existing, device) // Peer-supplied names arrive capped so a hostile peer cannot grow the // registry without limit (adoption + direct-pair seams land here). val nextDevice = bound.copy( deviceName = bound.deviceName?.let(::capDeviceName), localAlias = existing?.localAlias, ) val next = currentDevices.filterNot { it.deviceId == device.deviceId } + nextDevice persist(next) } } /** * Commit a staged manifest in one synchronous SharedPreferences transaction. * `apply()` is appropriate for ordinary UI writes, but it would expose each * per-device edit independently to observers and could leave an old-session * manifest half-applied across a lock. `commit()` updates both keys together * in memory and does not return until the durable write has completed. */ override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean { return mutationGate.withLock { val priorPrefs = readPrefsState() val currentDevices = getAll() val currentById = currentDevices.associateBy { it.deviceId } val currentRevocations = listRevocations() val nextRevocations = mergeRevocationRecords(currentRevocations, batch.incomingRevocations).records val removed = batch.removals val nextDevices = currentDevices .filterNot { removed.contains(it.deviceId) } .toMutableList() batch.upserts.forEach { incoming -> val existing = currentById[incoming.deviceId] ?: nextDevices.firstOrNull { it.deviceId == incoming.deviceId } require(!nextDevices.any { candidate -> candidate.deviceId != incoming.deviceId && ((candidate.longTermPublicKeyHex != null && incoming.longTermPublicKeyHex != null && CryptoCompare.hex(candidate.longTermPublicKeyHex, incoming.longTermPublicKeyHex)) || (candidate.signingPublicKeyHex != null && incoming.signingPublicKeyHex != null && CryptoCompare.hex(candidate.signingPublicKeyHex, incoming.signingPublicKeyHex))) }) { "device_identity_collision" } val bound = bindImmutableIdentity(existing, incoming) val alias = nextDevices.firstOrNull { it.deviceId == incoming.deviceId }?.localAlias val device = bound.copy( deviceName = bound.deviceName?.let(::capDeviceName), localAlias = alias, ) nextDevices.removeAll { it.deviceId == device.deviceId } nextDevices += device } val nextPrefs = RegistryPrefsState( devices = json.encodeToString(listSerializer, nextDevices), revocations = json.encodeToString(revocationSerializer, nextRevocations), ) // Explicitly restore the old encrypted-preference values when commit() // reports false. The platform usually keeps them in memory, but a real // store failure must not rely on that undocumented outcome. commitWithRollback(priorPrefs, nextPrefs, ::writePrefsState) } } fun rename(deviceId: String, name: String) { mutationGate.withLock { persist(withLocalAlias(getAll(), deviceId, name)) } } override fun remove(deviceId: String) { mutationGate.withLock { persist(getAll().filterNot { it.deviceId == deviceId }) } } // ── Device revocation (Part 5 §C) ──────────────────────────────────────── override fun listRevocations(): List { val raw = prefs.getString(KEY_REVOCATIONS, null) ?: return emptyList() return try { json.decodeFromString(revocationSerializer, raw) } catch (e: Exception) { throw TrustDataCorruptedException(KEY_REVOCATIONS, e.message ?: "decode_failed") } } /** * Revoke a device: record it, then drop it from the registry. The record * rides out in every manifest this device signs from now on. A stale entry * may survive in the pending-vouch queue; the confirm path re-checks * revocation before promoting anything out of it. */ fun revoke( deviceId: String, revokedByDeviceId: String, nowMs: Long = System.currentTimeMillis(), ): DeviceRevocation? = mutationGate.withLock { knownLocalDeviceId = revokedByDeviceId val currentDevices = getAll() val currentRevocations = listRevocations() val mutation = planDeviceRevocation( devices = currentDevices, revocations = currentRevocations, deviceId = deviceId, revokedByDeviceId = revokedByDeviceId, nowMs = nowMs, protectedIssuerIds = protectedRevocationIssuers(), ) // One verified transaction for both keys. Two independent apply() calls // can land separately, and a device removed without its revocation // record stops being blocked from re-admission — a silent failure of the // ejection path rather than a visible error. val nextPrefs = RegistryPrefsState( devices = json.encodeToString(listSerializer, mutation.devices), revocations = json.encodeToString(revocationSerializer, mutation.revocations), ) check(commitWithRollback(readPrefsState(), nextPrefs, ::writePrefsState)) { "paired_device_persist_failed" } mutation.added } /** * `revokedAt` is peer-supplied and is NOT used for ordering. Existing * terminal records remain authoritative; only unprotected relayed records * may be evicted when the bounded ledger is full. */ override fun mergeRevocations(incoming: List): List { return mutationGate.withLock { val existing = listRevocations() val merged = mergeRevocationRecords(existing, incoming, protectedRevocationIssuers()) if (merged.records != existing) persistRevocations(merged.records) merged.admitted.map { it.deviceId } } } /** * Revocations this device issued, and those issued by a direct peer it * SAS-confirmed, are terminal: evicting one to make room would re-admit a * device the ledger exists to block (PB-26-130). Only relayed/indirect * records may be pruned when the ledger is full. * * The local device id is learned from [revoke] rather than loaded here: the * identity loader can mint, and a trust-decision path must not have that * side effect. */ private fun protectedRevocationIssuers(): Set = buildSet { knownLocalDeviceId?.let(::add) getAll().filter { it.trustSource == "direct" }.forEach { add(it.deviceId) } } /** * Clear a revocation. The ONLY legitimate caller is a direct, SAS-confirmed * re-pair — a physically present user on both devices. Never call this from * a path a remote peer can reach. */ override fun clearRevocation(deviceId: String) { mutationGate.withLock { val existing = listRevocations() val filtered = existing.filterNot { it.deviceId == deviceId } if (filtered.size != existing.size) persistRevocations(filtered) } } fun clear() { mutationGate.withLock { val edit = prefs.edit() .remove(KEY_DEVICES) .remove(KEY_REVOCATIONS) // Known-key history is trust state: leaving it behind would keep a // cleared installation admitting keys it no longer holds devices // for, so it goes with the rest of the registry. for (key in prefs.all.keys.filter { it.startsWith(KEY_ISSUER_KEYS_PREFIX) }) { edit.remove(key) } edit.commit() } } /** Verbatim logical copy of the whole registry, for the secure-reset archive. */ internal fun snapshot(): Map = prefs.all /** * Refresh the device's `lastKnownIp` (the closest real analogue to the * brief's `lastSeen`). No-op when the id is unknown or [ip] is null. */ fun updateLastSeen(id: String, ip: String? = null) { mutationGate.withLock { val current = get(id) ?: return@withLock if (ip == null) return@withLock upsert(current.copy(lastKnownIp = ip)) } } fun updateLastSync(id: String, at: Long = System.currentTimeMillis()) { mutationGate.withLock { val current = get(id) ?: return@withLock upsert(current.copy(lastSyncedAt = at)) } } /** * Reconnect prefers each pair's relay over the global fallback, so a relay * change in Settings only reaches established pairs by being written into * them. Skips the write when nothing moved, so the observers behind the * device list do not churn on a refused change. */ fun propagateRelaySetting(mode: SyncMode, storedRelayUrl: String) { mutationGate.withLock { val current = getAll() val next = withRelaySetting(current, mode, storedRelayUrl) if (next != current) persist(next) } } /** * Observe the full list. EncryptedSharedPreferences supports * OnSharedPreferenceChangeListener, so this emits on every persist — * including writes from other components in-process. */ fun observeAll(): Flow> = callbackFlow { trySend(getAll()) val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key -> if (key == KEY_DEVICES || key == null) trySend(getAll()) } prefs.registerOnSharedPreferenceChangeListener(listener) awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) } } override fun issuerSigningKeys(issuerDeviceId: String): Set = prefs.getStringSet("$KEY_ISSUER_KEYS_PREFIX$issuerDeviceId", emptySet()) .orEmpty().map(String::lowercase).toSet() /** Any known-key history at all: legacy trust state for the reset probe. */ fun hasIssuerSigningKeys(): Boolean = prefs.all.keys.any { it.startsWith(KEY_ISSUER_KEYS_PREFIX) } override fun appendIssuerSigningKey(issuerDeviceId: String, signingKeyHex: String): Boolean { val key = signingKeyHex.lowercase() if (issuerDeviceId.isBlank() || key.length != 64) return false synchronized(mutationGate) { val existing = issuerSigningKeys(issuerDeviceId) if (key in existing) return true return prefs.edit() .putStringSet("$KEY_ISSUER_KEYS_PREFIX$issuerDeviceId", existing + key) .commit() } } /** * Revocation mutations must be durable and verified. `apply()` returns * before the write is on disk, and losing the terminal ledger silently * un-revokes devices, so the ledger is never written that way. */ private fun persistRevocations(records: List) { val prior = readPrefsState() val next = prior.copy( revocations = json.encodeToString(revocationSerializer, records), ) check(commitWithRollback(prior, next, ::writePrefsState)) { "paired_device_persist_failed" } } private fun persist(devices: List) { prefs.edit() .putString(KEY_DEVICES, json.encodeToString(listSerializer, devices)) .apply() } private data class RegistryPrefsState( val devices: String?, val revocations: String?, ) private fun readPrefsState(): RegistryPrefsState = RegistryPrefsState( devices = prefs.getString(KEY_DEVICES, null), revocations = prefs.getString(KEY_REVOCATIONS, null), ) private fun writePrefsState(state: RegistryPrefsState): Boolean { val edit = prefs.edit() if (state.devices == null) edit.remove(KEY_DEVICES) else edit.putString(KEY_DEVICES, state.devices) if (state.revocations == null) edit.remove(KEY_REVOCATIONS) else edit.putString(KEY_REVOCATIONS, state.revocations) return edit.commit() } private companion object { const val PREFS_FILE = "peach_paired_devices" const val KEY_DEVICES = "paired_devices" const val KEY_REVOCATIONS = "device_revocations" const val KEY_ISSUER_KEYS_PREFIX = "issuer_signing_keys:" const val MAX_REVOCATIONS = MAX_REVOCATION_RECORDS val json = Json { encodeDefaults = true; ignoreUnknownKeys = true } } } /** * §18.4: a corrupt persisted registry or revocation ledger is an integrity * error, not an empty list. Silently returning empty un-revokes every device. * The corrupt payload is preserved (the store never writes on this path) so a * future repair can attempt recovery. */ class TrustDataCorruptedException(key: String, detail: String) : RuntimeException("trust_data_corrupted:$key:$detail") /** Preserve a persisted device's cryptographic identity across metadata updates. */ internal fun bindImmutableIdentity(existing: PairedDevice?, incoming: PairedDevice): PairedDevice { if (existing == null) return incoming val longTermChanged = existing.longTermPublicKeyHex != null && incoming.longTermPublicKeyHex != null && !CryptoCompare.hex(existing.longTermPublicKeyHex, incoming.longTermPublicKeyHex) val signingChanged = existing.signingPublicKeyHex != null && incoming.signingPublicKeyHex != null && !CryptoCompare.hex(existing.signingPublicKeyHex, incoming.signingPublicKeyHex) require(!longTermChanged && !signingChanged) { "device_identity_immutable" } return incoming.copy( longTermPublicKeyHex = incoming.longTermPublicKeyHex ?: existing.longTermPublicKeyHex, signingPublicKeyHex = incoming.signingPublicKeyHex ?: existing.signingPublicKeyHex, ) } /** Serialises whole-list registry mutations so stale snapshots cannot win. */ internal class RegistryMutationGate { private val lock = ReentrantLock() fun withLock(block: () -> T): T = lock.withLock(block) } internal fun withLocalAlias( devices: List, deviceId: String, name: String, ): List { val alias = name.trim() require(alias.isNotEmpty()) { "device name must not be empty" } require(alias.length <= MAX_DEVICE_NAME_CHARS) { "device name must be 64 chars or fewer" } require(devices.any { it.deviceId == deviceId }) { "device_not_found" } return devices.map { if (it.deviceId == deviceId) it.copy(localAlias = alias) else it } } internal data class DeviceRevocationMutation( val devices: List, val revocations: List, val added: DeviceRevocation?, ) /** A fresh pairing ceremony must not reuse terminally revoked identity material. */ internal fun hasAdoptionIdentityConflict( incoming: PairedDevice, devices: List, revocations: List, ): Boolean { if (devices.any { it.deviceId == incoming.deviceId }) return true if (devices.any { it.longTermPublicKeyHex?.let { key -> incoming.longTermPublicKeyHex?.let { candidate -> CryptoCompare.hex(key, candidate) } } == true || it.signingPublicKeyHex?.let { key -> incoming.signingPublicKeyHex?.let { candidate -> CryptoCompare.hex(key, candidate) } } == true }) return true return revocations.any { revoked -> revoked.deviceId == incoming.deviceId || incoming.longTermPublicKeyHex?.let { CryptoCompare.hex(revoked.longTermPublicKeyHex, it) } == true } } /** * Compute one revoke transaction without Android storage so the impossible * "paired and already revoked" state can be regression-tested directly. * * Older builds could leave that overlap behind. A repeated Remove must still * drop the live row even though the terminal record itself is idempotent. */ internal fun planDeviceRevocation( devices: List, revocations: List, deviceId: String, revokedByDeviceId: String, nowMs: Long, protectedIssuerIds: Set = emptySet(), ): DeviceRevocationMutation { val remaining = devices.filterNot { it.deviceId == deviceId } if (revocations.any { it.deviceId == deviceId }) { return DeviceRevocationMutation(remaining, revocations, null) } val device = devices.firstOrNull { it.deviceId == deviceId } val record = DeviceRevocation( deviceId = deviceId, longTermPublicKeyHex = device?.longTermPublicKeyHex ?: "0".repeat(64), revokedAt = nowMs, revokedByDeviceId = revokedByDeviceId, ) return DeviceRevocationMutation( remaining, capRevocationRecords(revocations + record, protectedIssuerIds).records, record, ) } internal data class RevocationMergeResult( val records: List, val admitted: List, val outcome: RevocationMergeOutcome = RevocationMergeOutcome.MERGED, ) internal enum class RevocationMergeOutcome { MERGED, OVERFLOW_PROTECTED_LEDGER, } internal data class RevocationCapResult( val records: List, val outcome: RevocationMergeOutcome, ) internal fun mergeRevocationRecords( existing: List, incoming: List, protectedIssuerIds: Set = emptySet(), ): RevocationMergeResult { val known = existing.map { it.deviceId }.toSet() val fresh = incoming.filterNot { known.contains(it.deviceId) } val capped = capRevocationRecords(existing + fresh, protectedIssuerIds) if (capped.outcome == RevocationMergeOutcome.OVERFLOW_PROTECTED_LEDGER) { return RevocationMergeResult(existing, emptyList(), capped.outcome) } val admitted = fresh.filter { candidate -> capped.records.any { it.deviceId == candidate.deviceId } } return RevocationMergeResult(capped.records, admitted, capped.outcome) } /** * Keep the terminal ledger bounded. Records issued by this device or a direct * peer are protected; only relayed/indirect issuers are eviction candidates. * If all candidates are protected, refuse the new records rather than dropping * trusted state. The overflow outcome is explicit in the cap result. */ internal fun capRevocationRecords( records: List, protectedIssuerIds: Set = emptySet(), ): RevocationCapResult { if (records.size <= MAX_REVOCATION_RECORDS) { return RevocationCapResult(records, RevocationMergeOutcome.MERGED) } val candidates = records.indices.filter { records[it].revokedByDeviceId !in protectedIssuerIds } if (candidates.size < records.size - MAX_REVOCATION_RECORDS) { return RevocationCapResult(records, RevocationMergeOutcome.OVERFLOW_PROTECTED_LEDGER) } val dropped = candidates.take(records.size - MAX_REVOCATION_RECORDS).toSet() return RevocationCapResult( records.filterIndexed { index, _ -> index !in dropped }, RevocationMergeOutcome.MERGED, ) } /** * The mesh relay is pushed out through the same gate that decides whether to * dial one: a relay this device refuses to use must not be handed to peers * either — pushing Peach's own URL out under a "Private relay" label would tell * the same lie one layer down. */ internal fun withRelaySetting( devices: List, mode: SyncMode, storedRelayUrl: String, ): List = when (val decision = relayInForce(mode, storedRelayUrl)) { is RelayDecision.Dial -> devices.map { it.copy(relayUrl = decision.relayUrl) } is RelayDecision.Refuse -> devices }