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}383940@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 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}