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