LockedSyncSpoolStore.kt

9.2 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) {32    init {33        require(entries.size <= SyncLimits.MAX_ENTRY_COUNT) { "sync_entry_count_exceeded" }34        require(inboxEntries.size <= SyncLimits.MAX_ENTRY_COUNT) { "sync_entry_count_exceeded" }35        require(tombstones.size <= SyncLimits.MAX_TOMBSTONE_COUNT) { "sync_tombstone_count_exceeded" }36        require(inboxTombstones.size <= SyncLimits.MAX_TOMBSTONE_COUNT) { "sync_tombstone_count_exceeded" }37    }38}3940/** A Keystore-encrypted cache of opaque entry envelopes and transport identity. */41@Singleton42class LockedSyncSpoolStore @Inject constructor(43    @ApplicationContext private val context: Context,44) {45    private val masterKey = MasterKey.Builder(context)46        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)47        .build()48    private val prefs = EncryptedSharedPreferences.create(49        context,50        PREFS_FILE,51        masterKey,52        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,53        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,54    )5556    @Synchronized57    internal fun refresh(repo: VaultRepository) {58        val snapshot = repo.syncMasterKeySnapshot() ?: return59        val root = snapshot.key60        val deviceId = getOrCreateDeviceId(context)61        val keys = deriveSyncIdentityKeypair(root, deviceId)62        try {63            val prior = read()64            val entries = repo.getEntries().associate { entry ->65                entry.id to LockedSyncCachedEntry(66                    entry.modifiedAt,67                    VaultSyncAdapter.encryptEntryWithMasterKey(root, entry),68                )69            }70            if (!repo.isSessionEpochCurrent(snapshot.epoch)) return71            val signing = deriveSyncSigningKeypair(root, deviceId)72            write(73                LockedSyncSpool(74                    vaultId = computeVaultId(root),75                    deviceId = deviceId,76                    deviceName = getDeviceName(context),77                    localVersion = repo.getVaultVersion(),78                    longTermPublicKeyHex = pubkeyToHex(keys.publicKey),79                    longTermPrivateKeyHex = pubkeyToHex(keys.secretKey),80                    signingPublicKeyHex = pubkeyToHex(signing.publicKeyRaw),81                    entries = entries,82                    tombstones = repo.getTombstones().associate { it.id to it.deletedAt },83                    inboxEntries = prior?.inboxEntries ?: emptyMap(),84                    inboxTombstones = prior?.inboxTombstones ?: emptyMap(),85                ),86            )87        } finally {88            VaultCrypto.zero(root)89            VaultCrypto.zero(keys.secretKey)90        }91    }9293    internal fun identity(): Pair<SyncIdentityKeypair, LocalDeviceIdentity>? {94        val state = read() ?: return null95        val privateKey = runCatching { hexToPubkey(state.longTermPrivateKeyHex) }.getOrNull() ?: return null96        val publicKey = runCatching { hexToPubkey(state.longTermPublicKeyHex) }.getOrNull() ?: return null97        return SyncIdentityKeypair(publicKey, privateKey) to LocalDeviceIdentity(98            deviceId = state.deviceId,99            deviceName = state.deviceName,100            deviceType = DeviceType.ANDROID,101            longTermPublicKeyHex = state.longTermPublicKeyHex,102            signingPublicKeyHex = state.signingPublicKeyHex,103            relayUrl = RelaySettingsStore.readRelayUrl(context),104        )105    }106107    internal fun adapter(): SyncVaultAdapter? = read()?.let { LockedSyncVaultAdapter(this, it.vaultId) }108109    internal suspend fun drainInto(repo: VaultRepository) {110        val staged = read() ?: return111        if (staged.inboxEntries.isNotEmpty() || staged.inboxTombstones.isNotEmpty()) {112            val live = VaultSyncAdapter(repo, staged.deviceId)113            live.applyEntries(114                staged.inboxEntries.mapValues { it.value.ciphertext },115                staged.inboxEntries.mapValues { it.value.modifiedAt },116            )117            live.applyTombstones(staged.inboxTombstones)118            synchronized(this) {119                val current = read() ?: return@synchronized120                write(121                    current.copy(122                        inboxEntries = current.inboxEntries.filter { (id, value) ->123                            staged.inboxEntries[id]?.ciphertext != value.ciphertext124                        },125                        inboxTombstones = current.inboxTombstones.filter { (id, value) ->126                            staged.inboxTombstones[id] != value127                        },128                    ),129                )130            }131        }132        refresh(repo)133    }134135    @Synchronized136    internal fun stageEntries(vaultId: String, entries: Map<String, String>, manifest: Map<String, Long>) {137        val current = read()?.takeIf { it.vaultId == vaultId } ?: return138        require(entries.size <= SyncLimits.MAX_ENTRY_COUNT) { "sync_entry_count_exceeded" }139        val now = System.currentTimeMillis()140        val inbox = current.inboxEntries.toMutableMap()141        entries.forEach { (id, ciphertext) ->142            val claimed = manifest[id] ?: return@forEach143            if (claimed <= 0L || ciphertext.length > SyncLimits.MAX_ENTRY_ENVELOPE_BYTES) return@forEach144            val modifiedAt = SyncLimits.clampSyncTimestamp(claimed, now)145            if ((inbox[id]?.modifiedAt ?: 0L) < modifiedAt) {146                inbox[id] = LockedSyncCachedEntry(modifiedAt, ciphertext)147            }148        }149        write(current.copy(inboxEntries = inbox))150    }151152    @Synchronized153    internal fun stageTombstones(vaultId: String, tombstones: Map<String, Long>) {154        val current = read()?.takeIf { it.vaultId == vaultId } ?: return155        require(tombstones.size <= SyncLimits.MAX_TOMBSTONE_COUNT) { "sync_tombstone_count_exceeded" }156        val now = System.currentTimeMillis()157        val inbox = current.inboxTombstones.toMutableMap()158        tombstones.forEach { (id, timestamp) ->159            if (timestamp <= 0L) return@forEach160            val deletedAt = SyncLimits.clampSyncTimestamp(timestamp, now)161            if ((inbox[id] ?: 0L) < deletedAt) inbox[id] = deletedAt162        }163        write(current.copy(inboxTombstones = inbox))164    }165166    internal fun snapshot(vaultId: String): LockedSyncSpool? = read()?.takeIf { it.vaultId == vaultId }167    fun clear() { prefs.edit().remove(KEY_STATE).commit() }168169    private fun read(): LockedSyncSpool? {170        val raw = prefs.getString(KEY_STATE, null) ?: return null171        return runCatching { json.decodeFromString(LockedSyncSpool.serializer(), raw) }172            .getOrNull()173            ?.takeIf { it.version == 1 && it.vaultId.matches(Regex("^[0-9a-f]{64}$")) }174    }175176    private fun write(state: LockedSyncSpool) {177        check(prefs.edit().putString(KEY_STATE, json.encodeToString(LockedSyncSpool.serializer(), state)).commit()) {178            "locked_sync_spool_write_failed"179        }180    }181182    private companion object {183        const val PREFS_FILE = "peach_locked_sync_spool_v1"184        const val KEY_STATE = "state"185        val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }186    }187}188189internal class LockedSyncVaultAdapter(190    private val store: LockedSyncSpoolStore,191    private val vaultId: String,192) : SyncVaultAdapter {193    private fun state() = store.snapshot(vaultId) ?: throw VaultLockedException()194    override suspend fun computeVaultId() = vaultId195    override suspend fun getOurDeviceId() = state().deviceId196    override suspend fun getLocalVersion() = state().localVersion197    override suspend fun buildEntryManifest() = state().entries.mapValues { it.value.modifiedAt }198    override suspend fun buildTombstoneManifest() = state().tombstones199    override suspend fun getEncryptedEntries(entryIds: List<String>): Map<String, String> {200        val entries = state().entries201        return entryIds.mapNotNull { id -> entries[id]?.ciphertext?.let { id to it } }.toMap()202    }203    override suspend fun applyEntries(entries: Map<String, String>, remoteManifest: Map<String, Long>) =204        store.stageEntries(vaultId, entries, remoteManifest)205    override suspend fun applyTombstones(tombstones: Map<String, Long>) =206        store.stageTombstones(vaultId, tombstones)207}208209internal object LockedSyncSpoolHooks {210    @Volatile var refresh: (VaultRepository) -> Unit = {}211}