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 org.bouncycastle.crypto.params.X25519PrivateKeyParameters12import java.security.MessageDigest13import javax.inject.Inject14import javax.inject.Singleton1516@Serializable17internal data class LockedSyncCachedEntry(val modifiedAt: Long, val ciphertext: String)1819@Serializable20internal data class LockedSyncSpool(21 val version: Int = 1,22 val vaultId: String,23 val deviceId: String,24 val deviceName: String,25 val localVersion: Long,26 val longTermPublicKeyHex: String,27 val longTermPrivateKeyHex: String? = null,28 val signingPublicKeyHex: String,29 val entries: Map<String, LockedSyncCachedEntry>,30 val tombstones: Map<String, Long>,31 val inboxEntries: Map<String, LockedSyncCachedEntry> = emptyMap(),32 val inboxTombstones: Map<String, Long> = emptyMap(),33) {34 init {35 require(entries.size <= SyncLimits.MAX_ENTRY_COUNT) { "sync_entry_count_exceeded" }36 require(inboxEntries.size <= SyncLimits.MAX_ENTRY_COUNT) { "sync_entry_count_exceeded" }37 require(tombstones.size <= SyncLimits.MAX_TOMBSTONE_COUNT) { "sync_tombstone_count_exceeded" }38 require(inboxTombstones.size <= SyncLimits.MAX_TOMBSTONE_COUNT) { "sync_tombstone_count_exceeded" }39 }40}414243@Singleton44class LockedSyncSpoolStore @Inject constructor(45 @ApplicationContext private val context: Context,46) {47 private val masterKey = MasterKey.Builder(context)48 .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)49 .build()50 private val prefs = EncryptedSharedPreferences.create(51 context,52 PREFS_FILE,53 masterKey,54 EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,55 EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,56 )5758 @Synchronized59 internal fun refresh(repo: VaultRepository) {60 val snapshot = repo.syncMasterKeySnapshot() ?: return61 val root = snapshot.key62 val credentials = loadOrCreateLocalIdentity(context)63 val deviceId = credentials.deviceId64 val keys = localSyncKeypair(credentials)65 try {66 val prior = read()67 val entries = repo.getEntries().associate { entry ->68 entry.id to LockedSyncCachedEntry(69 entry.modifiedAt,70 VaultSyncAdapter.encryptEntryWithMasterKey(root, entry),71 )72 }73 if (!repo.isSessionEpochCurrent(snapshot.epoch)) return74 val signing = localSigningKeypair(credentials)75 write(76 LockedSyncSpool(77 vaultId = computeVaultId(root),78 deviceId = deviceId,79 deviceName = getDeviceName(context),80 localVersion = repo.getVaultVersion(),81 longTermPublicKeyHex = pubkeyToHex(keys.publicKey),82 longTermPrivateKeyHex = pubkeyToHex(keys.secretKey),83 signingPublicKeyHex = pubkeyToHex(signing.publicKeyRaw),84 entries = entries,85 tombstones = repo.getTombstones().associate { it.id to it.deletedAt },86 inboxEntries = prior?.inboxEntries ?: emptyMap(),87 inboxTombstones = prior?.inboxTombstones ?: emptyMap(),88 ),89 )90 } finally {91 VaultCrypto.zero(root)92 VaultCrypto.zero(keys.secretKey)93 }94 }9596 internal fun identity(): Pair<SyncIdentityKeypair, LocalDeviceIdentity>? {97 val state = read() ?: return null98 return lockedSyncIdentity(state, RelaySettingsStore.readRelayUrl(context))99 }100101 internal fun adapter(): SyncVaultAdapter? = read()?.let { state ->102 LockedSyncVaultAdapter(103 vaultId = state.vaultId,104 readSnapshot = { snapshot(state.vaultId) },105 stageEntriesInto = { entries, manifest -> stageEntries(state.vaultId, entries, manifest) },106 stageTombstonesInto = { tombstones -> stageTombstones(state.vaultId, tombstones) },107 )108 }109110 internal suspend fun drainInto(repo: VaultRepository) {111 val staged = read() ?: return112 if (staged.inboxEntries.isNotEmpty() || staged.inboxTombstones.isNotEmpty()) {113 val live = VaultSyncAdapter(repo, staged.deviceId)114 live.applyEntries(115 staged.inboxEntries.mapValues { it.value.ciphertext },116 staged.inboxEntries.mapValues { it.value.modifiedAt },117 )118 live.applyTombstones(staged.inboxTombstones)119 synchronized(this) {120 val current = read() ?: return@synchronized121 write(122 current.copy(123 inboxEntries = current.inboxEntries.filter { (id, value) ->124 staged.inboxEntries[id]?.ciphertext != value.ciphertext125 },126 inboxTombstones = current.inboxTombstones.filter { (id, value) ->127 staged.inboxTombstones[id] != value128 },129 ),130 )131 }132 }133 refresh(repo)134 }135136 @Synchronized137 internal fun stageEntries(vaultId: String, entries: Map<String, String>, manifest: Map<String, Long>) {138 val current = read()?.takeIf { it.vaultId == vaultId } ?: return139 require(entries.size <= SyncLimits.MAX_ENTRY_COUNT) { "sync_entry_count_exceeded" }140 val now = System.currentTimeMillis()141 val inbox = current.inboxEntries.toMutableMap()142 entries.forEach { (id, ciphertext) ->143 val claimed = manifest[id] ?: return@forEach144 if (claimed <= 0L || ciphertext.length > SyncLimits.MAX_ENTRY_ENVELOPE_BYTES) return@forEach145 val modifiedAt = SyncLimits.clampSyncTimestamp(claimed, now)146 if ((inbox[id]?.modifiedAt ?: 0L) < modifiedAt) {147 inbox[id] = LockedSyncCachedEntry(modifiedAt, ciphertext)148 }149 }150 write(current.copy(inboxEntries = inbox))151 }152153 @Synchronized154 internal fun stageTombstones(vaultId: String, tombstones: Map<String, Long>) {155 val current = read()?.takeIf { it.vaultId == vaultId } ?: return156 require(tombstones.size <= SyncLimits.MAX_TOMBSTONE_COUNT) { "sync_tombstone_count_exceeded" }157 val now = System.currentTimeMillis()158 val inbox = current.inboxTombstones.toMutableMap()159 tombstones.forEach { (id, timestamp) ->160 if (timestamp <= 0L) return@forEach161 val deletedAt = SyncLimits.clampSyncTimestamp(timestamp, now)162 if ((inbox[id] ?: 0L) < deletedAt) inbox[id] = deletedAt163 }164 write(current.copy(inboxTombstones = inbox))165 }166167 internal fun snapshot(vaultId: String): LockedSyncSpool? = read()?.takeIf { it.vaultId == vaultId }168 fun clear() { prefs.edit().remove(KEY_STATE).commit() }169170 private fun read(): LockedSyncSpool? {171 val raw = prefs.getString(KEY_STATE, null) ?: return null172 return runCatching { json.decodeFromString(LockedSyncSpool.serializer(), raw) }173 .getOrNull()174 ?.takeIf { it.version == 1 && it.vaultId.matches(Regex("^[0-9a-f]{64}$")) }175 }176177 private fun write(state: LockedSyncSpool) {178 check(prefs.edit().putString(KEY_STATE, json.encodeToString(LockedSyncSpool.serializer(), state)).commit()) {179 "locked_sync_spool_write_failed"180 }181 }182183 private companion object {184 const val PREFS_FILE = "peach_locked_sync_spool_v1"185 const val KEY_STATE = "state"186 val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }187 }188}189190internal fun lockedSyncIdentity(191 state: LockedSyncSpool,192 relayUrl: String,193): Pair<SyncIdentityKeypair, LocalDeviceIdentity>? {194 val privateHex = state.longTermPrivateKeyHex ?: return null195 val secret = runCatching { hexToBytes(privateHex) }.getOrNull() ?: return null196 if (secret.size != 32) {197 VaultCrypto.zero(secret)198 return null199 }200 return try {201 val public = X25519PrivateKeyParameters(secret, 0).generatePublicKey().encoded202 val expectedPublic = hexToPubkey(state.longTermPublicKeyHex)203 if (!MessageDigest.isEqual(public, expectedPublic)) {204 VaultCrypto.zero(public)205 VaultCrypto.zero(secret)206 null207 } else {208 SyncIdentityKeypair(public, secret) to LocalDeviceIdentity(209 deviceId = state.deviceId,210 deviceName = state.deviceName,211 deviceType = DeviceType.ANDROID,212 longTermPublicKeyHex = state.longTermPublicKeyHex,213 signingPublicKeyHex = state.signingPublicKeyHex,214 relayUrl = relayUrl,215 )216 }217 } catch (_: RuntimeException) {218 VaultCrypto.zero(secret)219 null220 }221}222223224225226227228229230231232233234235236237238239240internal class LockedSyncVaultAdapter(241 private val vaultId: String,242 private val readSnapshot: () -> LockedSyncSpool?,243 private val stageEntriesInto: (Map<String, String>, Map<String, Long>) -> Unit,244 private val stageTombstonesInto: (Map<String, Long>) -> Unit,245) : SyncVaultAdapter {246 private fun state() = readSnapshot() ?: throw VaultLockedException()247 override suspend fun computeVaultId() = vaultId248 override suspend fun getOurDeviceId() = state().deviceId249 override suspend fun getLocalVersion() = state().localVersion250 override suspend fun buildEntryManifest() = state().entries.mapValues { it.value.modifiedAt }251 override suspend fun buildTombstoneManifest() = state().tombstones252 override suspend fun getEncryptedEntries(entryIds: List<String>): Map<String, String> {253 val entries = state().entries254 return entryIds.mapNotNull { id -> entries[id]?.ciphertext?.let { id to it } }.toMap()255 }256257 258 override suspend fun applyEntries(entries: Map<String, String>, remoteManifest: Map<String, Long>) =259 stageEntriesInto(entries, remoteManifest)260261 override suspend fun applyTombstones(tombstones: Map<String, Long>) =262 stageTombstonesInto(tombstones)263264 override val supportsDeviceManifest: Boolean = false265266 override suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest =267 throw VaultLockedException()268269 override suspend fun applyIncomingDeviceManifest(270 manifest: SignedDeviceManifest,271 authenticatedRemoteStaticPublicKey: ByteArray,272 ): Unit = throw VaultLockedException()273}274275internal object LockedSyncSpoolHooks {276 @Volatile var refresh: (VaultRepository) -> Unit = {}277}