LockedSyncSpoolStore.kt

8.8 KB · raw

1package com.peachpasswords.sync23import android.content.Context4import androidx.security.crypto.EncryptedSharedPreferences5import androidx.security.crypto.MasterKey6import com.peachpasswords.vault.VaultCrypto7import com.peachpasswords.vault.VaultRepository8import dagger.hilt.android.qualifiers.ApplicationContext9import kotlinx.serialization.Serializable10import kotlinx.serialization.json.Json11import javax.inject.Inject12import javax.inject.Singleton1314@Serializable15internal data class LockedSyncCachedEntry(val modifiedAt: Long, val ciphertext: String)1617@Serializable18internal data class LockedSyncSpool(19    val version: Int = 1,20    val vaultId: String,21    val deviceId: String,22    val deviceName: String,23    val localVersion: Long,24    val longTermPublicKeyHex: String,25    val longTermPrivateKeyHex: String,26    val signingPublicKeyHex: String,27    val entries: Map<String, LockedSyncCachedEntry>,28    val tombstones: Map<String, Long>,29    val inboxEntries: Map<String, LockedSyncCachedEntry> = emptyMap(),30    val inboxTombstones: Map<String, Long> = emptyMap(),31)3233/** A Keystore-encrypted cache of opaque entry envelopes and transport identity. */34@Singleton35class LockedSyncSpoolStore @Inject constructor(36    @ApplicationContext private val context: Context,37) {38    private val masterKey = MasterKey.Builder(context)39        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)40        .build()41    private val prefs = EncryptedSharedPreferences.create(42        context,43        PREFS_FILE,44        masterKey,45        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,46        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,47    )4849    @Synchronized50    internal fun refresh(repo: VaultRepository) {51        val snapshot = repo.syncMasterKeySnapshot() ?: return52        val root = snapshot.key53        val deviceId = getOrCreateDeviceId(context)54        val keys = deriveSyncIdentityKeypair(root, deviceId)55        try {56            val prior = read()57            val entries = repo.getEntries().associate { entry ->58                entry.id to LockedSyncCachedEntry(59                    entry.modifiedAt,60                    VaultSyncAdapter.encryptEntryWithMasterKey(root, entry),61                )62            }63            if (!repo.isSessionEpochCurrent(snapshot.epoch)) return64            val signing = deriveSyncSigningKeypair(root, deviceId)65            write(66                LockedSyncSpool(67                    vaultId = computeVaultId(root),68                    deviceId = deviceId,69                    deviceName = getDeviceName(context),70                    localVersion = repo.getVaultVersion(),71                    longTermPublicKeyHex = pubkeyToHex(keys.publicKey),72                    longTermPrivateKeyHex = pubkeyToHex(keys.secretKey),73                    signingPublicKeyHex = pubkeyToHex(signing.publicKeyRaw),74                    entries = entries,75                    tombstones = repo.getTombstones().associate { it.id to it.deletedAt },76                    inboxEntries = prior?.inboxEntries ?: emptyMap(),77                    inboxTombstones = prior?.inboxTombstones ?: emptyMap(),78                ),79            )80        } finally {81            VaultCrypto.zero(root)82            VaultCrypto.zero(keys.secretKey)83        }84    }8586    internal fun identity(): Pair<SyncIdentityKeypair, LocalDeviceIdentity>? {87        val state = read() ?: return null88        val privateKey = runCatching { hexToPubkey(state.longTermPrivateKeyHex) }.getOrNull() ?: return null89        val publicKey = runCatching { hexToPubkey(state.longTermPublicKeyHex) }.getOrNull() ?: return null90        return SyncIdentityKeypair(publicKey, privateKey) to LocalDeviceIdentity(91            deviceId = state.deviceId,92            deviceName = state.deviceName,93            deviceType = DeviceType.ANDROID,94            longTermPublicKeyHex = state.longTermPublicKeyHex,95            signingPublicKeyHex = state.signingPublicKeyHex,96            relayUrl = RelaySettingsStore.readRelayUrl(context),97        )98    }99100    internal fun adapter(): SyncVaultAdapter? = read()?.let { LockedSyncVaultAdapter(this, it.vaultId) }101102    internal suspend fun drainInto(repo: VaultRepository) {103        val staged = read() ?: return104        if (staged.inboxEntries.isNotEmpty() || staged.inboxTombstones.isNotEmpty()) {105            val live = VaultSyncAdapter(repo, staged.deviceId)106            live.applyEntries(107                staged.inboxEntries.mapValues { it.value.ciphertext },108                staged.inboxEntries.mapValues { it.value.modifiedAt },109            )110            live.applyTombstones(staged.inboxTombstones)111            synchronized(this) {112                val current = read() ?: return@synchronized113                write(114                    current.copy(115                        inboxEntries = current.inboxEntries.filter { (id, value) ->116                            staged.inboxEntries[id]?.ciphertext != value.ciphertext117                        },118                        inboxTombstones = current.inboxTombstones.filter { (id, value) ->119                            staged.inboxTombstones[id] != value120                        },121                    ),122                )123            }124        }125        refresh(repo)126    }127128    @Synchronized129    internal fun stageEntries(vaultId: String, entries: Map<String, String>, manifest: Map<String, Long>) {130        val current = read()?.takeIf { it.vaultId == vaultId } ?: return131        require(entries.size <= SyncLimits.MAX_ENTRY_COUNT) { "sync_entry_count_exceeded" }132        val now = System.currentTimeMillis()133        val inbox = current.inboxEntries.toMutableMap()134        entries.forEach { (id, ciphertext) ->135            val claimed = manifest[id] ?: return@forEach136            if (claimed <= 0L || ciphertext.length > SyncLimits.MAX_ENTRY_ENVELOPE_BYTES) return@forEach137            val modifiedAt = SyncLimits.clampSyncTimestamp(claimed, now)138            if ((inbox[id]?.modifiedAt ?: 0L) < modifiedAt) {139                inbox[id] = LockedSyncCachedEntry(modifiedAt, ciphertext)140            }141        }142        write(current.copy(inboxEntries = inbox))143    }144145    @Synchronized146    internal fun stageTombstones(vaultId: String, tombstones: Map<String, Long>) {147        val current = read()?.takeIf { it.vaultId == vaultId } ?: return148        require(tombstones.size <= SyncLimits.MAX_TOMBSTONE_COUNT) { "sync_tombstone_count_exceeded" }149        val now = System.currentTimeMillis()150        val inbox = current.inboxTombstones.toMutableMap()151        tombstones.forEach { (id, timestamp) ->152            if (timestamp <= 0L) return@forEach153            val deletedAt = SyncLimits.clampSyncTimestamp(timestamp, now)154            if ((inbox[id] ?: 0L) < deletedAt) inbox[id] = deletedAt155        }156        write(current.copy(inboxTombstones = inbox))157    }158159    internal fun snapshot(vaultId: String): LockedSyncSpool? = read()?.takeIf { it.vaultId == vaultId }160    fun clear() { prefs.edit().remove(KEY_STATE).commit() }161162    private fun read(): LockedSyncSpool? {163        val raw = prefs.getString(KEY_STATE, null) ?: return null164        return runCatching { json.decodeFromString(LockedSyncSpool.serializer(), raw) }165            .getOrNull()166            ?.takeIf { it.version == 1 && it.vaultId.matches(Regex("^[0-9a-f]{64}$")) }167    }168169    private fun write(state: LockedSyncSpool) {170        check(prefs.edit().putString(KEY_STATE, json.encodeToString(LockedSyncSpool.serializer(), state)).commit()) {171            "locked_sync_spool_write_failed"172        }173    }174175    private companion object {176        const val PREFS_FILE = "peach_locked_sync_spool_v1"177        const val KEY_STATE = "state"178        val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }179    }180}181182internal class LockedSyncVaultAdapter(183    private val store: LockedSyncSpoolStore,184    private val vaultId: String,185) : SyncVaultAdapter {186    private fun state() = store.snapshot(vaultId) ?: throw VaultLockedException()187    override suspend fun computeVaultId() = vaultId188    override suspend fun getOurDeviceId() = state().deviceId189    override suspend fun getLocalVersion() = state().localVersion190    override suspend fun buildEntryManifest() = state().entries.mapValues { it.value.modifiedAt }191    override suspend fun buildTombstoneManifest() = state().tombstones192    override suspend fun getEncryptedEntries(entryIds: List<String>): Map<String, String> {193        val entries = state().entries194        return entryIds.mapNotNull { id -> entries[id]?.ciphertext?.let { id to it } }.toMap()195    }196    override suspend fun applyEntries(entries: Map<String, String>, remoteManifest: Map<String, Long>) =197        store.stageEntries(vaultId, entries, remoteManifest)198    override suspend fun applyTombstones(tombstones: Map<String, Long>) =199        store.stageTombstones(vaultId, tombstones)200}201202internal object LockedSyncSpoolHooks {203    @Volatile var refresh: (VaultRepository) -> Unit = {}204}