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)323334@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}