LockedSyncSpoolStore.kt

8.5 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 signingPublicKeyHex: String,26    val entries: Map<String, LockedSyncCachedEntry>,27    val tombstones: Map<String, Long>,28    val inboxEntries: Map<String, LockedSyncCachedEntry> = emptyMap(),29    val inboxTombstones: Map<String, Long> = emptyMap(),30) {31    init {32        require(entries.size <= SyncLimits.MAX_ENTRY_COUNT) { "sync_entry_count_exceeded" }33        require(inboxEntries.size <= SyncLimits.MAX_ENTRY_COUNT) { "sync_entry_count_exceeded" }34        require(tombstones.size <= SyncLimits.MAX_TOMBSTONE_COUNT) { "sync_tombstone_count_exceeded" }35        require(inboxTombstones.size <= SyncLimits.MAX_TOMBSTONE_COUNT) { "sync_tombstone_count_exceeded" }36    }37}3839/** A Keystore-encrypted cache of opaque entry envelopes and transport identity. */40@Singleton41class LockedSyncSpoolStore @Inject constructor(42    @ApplicationContext private val context: Context,43) {44    private val masterKey = MasterKey.Builder(context)45        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)46        .build()47    private val prefs = EncryptedSharedPreferences.create(48        context,49        PREFS_FILE,50        masterKey,51        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,52        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,53    )5455    @Synchronized56    internal fun refresh(repo: VaultRepository) {57        val snapshot = repo.syncMasterKeySnapshot() ?: return58        val root = snapshot.key59        val deviceId = getOrCreateDeviceId(context)60        val keys = deriveSyncIdentityKeypair(root, deviceId)61        try {62            val prior = read()63            val entries = repo.getEntries().associate { entry ->64                entry.id to LockedSyncCachedEntry(65                    entry.modifiedAt,66                    VaultSyncAdapter.encryptEntryWithMasterKey(root, entry),67                )68            }69            if (!repo.isSessionEpochCurrent(snapshot.epoch)) return70            val signing = deriveSyncSigningKeypair(root, deviceId)71            write(72                LockedSyncSpool(73                    vaultId = computeVaultId(root),74                    deviceId = deviceId,75                    deviceName = getDeviceName(context),76                    localVersion = repo.getVaultVersion(),77                    longTermPublicKeyHex = pubkeyToHex(keys.publicKey),78                    signingPublicKeyHex = pubkeyToHex(signing.publicKeyRaw),79                    entries = entries,80                    tombstones = repo.getTombstones().associate { it.id to it.deletedAt },81                    inboxEntries = prior?.inboxEntries ?: emptyMap(),82                    inboxTombstones = prior?.inboxTombstones ?: emptyMap(),83                ),84            )85        } finally {86            VaultCrypto.zero(root)87            VaultCrypto.zero(keys.secretKey)88        }89    }9091    internal fun identity(): Pair<SyncIdentityKeypair, LocalDeviceIdentity>? {92        // The private key is intentionally not persisted; authenticated Noise waits for unlock.93        return null94    }9596    internal fun adapter(): SyncVaultAdapter? = read()?.let { LockedSyncVaultAdapter(this, it.vaultId) }9798    internal suspend fun drainInto(repo: VaultRepository) {99        val staged = read() ?: return100        if (staged.inboxEntries.isNotEmpty() || staged.inboxTombstones.isNotEmpty()) {101            val live = VaultSyncAdapter(repo, staged.deviceId)102            live.applyEntries(103                staged.inboxEntries.mapValues { it.value.ciphertext },104                staged.inboxEntries.mapValues { it.value.modifiedAt },105            )106            live.applyTombstones(staged.inboxTombstones)107            synchronized(this) {108                val current = read() ?: return@synchronized109                write(110                    current.copy(111                        inboxEntries = current.inboxEntries.filter { (id, value) ->112                            staged.inboxEntries[id]?.ciphertext != value.ciphertext113                        },114                        inboxTombstones = current.inboxTombstones.filter { (id, value) ->115                            staged.inboxTombstones[id] != value116                        },117                    ),118                )119            }120        }121        refresh(repo)122    }123124    @Synchronized125    internal fun stageEntries(vaultId: String, entries: Map<String, String>, manifest: Map<String, Long>) {126        val current = read()?.takeIf { it.vaultId == vaultId } ?: return127        require(entries.size <= SyncLimits.MAX_ENTRY_COUNT) { "sync_entry_count_exceeded" }128        val now = System.currentTimeMillis()129        val inbox = current.inboxEntries.toMutableMap()130        entries.forEach { (id, ciphertext) ->131            val claimed = manifest[id] ?: return@forEach132            if (claimed <= 0L || ciphertext.length > SyncLimits.MAX_ENTRY_ENVELOPE_BYTES) return@forEach133            val modifiedAt = SyncLimits.clampSyncTimestamp(claimed, now)134            if ((inbox[id]?.modifiedAt ?: 0L) < modifiedAt) {135                inbox[id] = LockedSyncCachedEntry(modifiedAt, ciphertext)136            }137        }138        write(current.copy(inboxEntries = inbox))139    }140141    @Synchronized142    internal fun stageTombstones(vaultId: String, tombstones: Map<String, Long>) {143        val current = read()?.takeIf { it.vaultId == vaultId } ?: return144        require(tombstones.size <= SyncLimits.MAX_TOMBSTONE_COUNT) { "sync_tombstone_count_exceeded" }145        val now = System.currentTimeMillis()146        val inbox = current.inboxTombstones.toMutableMap()147        tombstones.forEach { (id, timestamp) ->148            if (timestamp <= 0L) return@forEach149            val deletedAt = SyncLimits.clampSyncTimestamp(timestamp, now)150            if ((inbox[id] ?: 0L) < deletedAt) inbox[id] = deletedAt151        }152        write(current.copy(inboxTombstones = inbox))153    }154155    internal fun snapshot(vaultId: String): LockedSyncSpool? = read()?.takeIf { it.vaultId == vaultId }156    fun clear() { prefs.edit().remove(KEY_STATE).commit() }157158    private fun read(): LockedSyncSpool? {159        val raw = prefs.getString(KEY_STATE, null) ?: return null160        return runCatching { json.decodeFromString(LockedSyncSpool.serializer(), raw) }161            .getOrNull()162            ?.takeIf { it.version == 1 && it.vaultId.matches(Regex("^[0-9a-f]{64}$")) }163    }164165    private fun write(state: LockedSyncSpool) {166        check(prefs.edit().putString(KEY_STATE, json.encodeToString(LockedSyncSpool.serializer(), state)).commit()) {167            "locked_sync_spool_write_failed"168        }169    }170171    private companion object {172        const val PREFS_FILE = "peach_locked_sync_spool_v1"173        const val KEY_STATE = "state"174        val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }175    }176}177178internal class LockedSyncVaultAdapter(179    private val store: LockedSyncSpoolStore,180    private val vaultId: String,181) : SyncVaultAdapter {182    private fun state() = store.snapshot(vaultId) ?: throw VaultLockedException()183    override suspend fun computeVaultId() = vaultId184    override suspend fun getOurDeviceId() = state().deviceId185    override suspend fun getLocalVersion() = state().localVersion186    override suspend fun buildEntryManifest() = state().entries.mapValues { it.value.modifiedAt }187    override suspend fun buildTombstoneManifest() = state().tombstones188    override suspend fun getEncryptedEntries(entryIds: List<String>): Map<String, String> {189        val entries = state().entries190        return entryIds.mapNotNull { id -> entries[id]?.ciphertext?.let { id to it } }.toMap()191    }192    override suspend fun applyEntries(entries: Map<String, String>, remoteManifest: Map<String, Long>) =193        store.stageEntries(vaultId, entries, remoteManifest)194    override suspend fun applyTombstones(tombstones: Map<String, Long>) =195        store.stageTombstones(vaultId, tombstones)196}197198internal object LockedSyncSpoolHooks {199    @Volatile var refresh: (VaultRepository) -> Unit = {}200}