1package com.peachpasswords.sync23import com.peachpasswords.vault.Tombstone4import com.peachpasswords.vault.VaultEntry5import com.peachpasswords.vault.VaultRepository6import kotlinx.serialization.json.Json7import org.bouncycastle.crypto.digests.SHA256Digest8import org.bouncycastle.crypto.generators.HKDFBytesGenerator9import org.bouncycastle.crypto.params.HKDFParameters10import java.security.SecureRandom11import java.util.Base6412import javax.crypto.Cipher13import javax.crypto.spec.GCMParameterSpec14import javax.crypto.spec.SecretKeySpec1516171819202122232425262728293031class VaultSyncAdapter(32 private val repo: VaultRepository,33 private val ourDeviceId: String,34 override val supportsDeviceManifest: Boolean = false,35) : ChirpSyncVaultAdapter {3637 override suspend fun computeVaultId(): String =38 repo.syncVaultId() ?: throw VaultLockedException()3940 override suspend fun getOurDeviceId(): String = ourDeviceId4142 override suspend fun getLocalVersion(): Long = repo.getVaultVersion()4344 override suspend fun buildEntryManifest(): Map<String, Long> =45 repo.getEntries().associate { it.id to it.modifiedAt }4647 override suspend fun buildTombstoneManifest(): Map<String, Long> =48 repo.getTombstones().associate { it.id to it.deletedAt }4950 override suspend fun getEncryptedEntries(entryIds: List<String>): Map<String, String> {51 val master = requireMasterKey()52 try {53 val out = LinkedHashMap<String, String>()54 for (id in entryIds) {55 val entry = repo.getEntry(id) ?: continue56 out[id] = encryptEntry(deriveEntryKey(master, id), entry)57 }58 return out59 } finally {60 master.fill(0)61 }62 }6364 override suspend fun applyEntries(entries: Map<String, String>) {65 val master = requireMasterKey()66 val decrypted = try {67 entries.mapNotNull { (id, envelope) ->68 val entry = decryptEntryOrNull(deriveEntryKey(master, id), envelope)69 70 71 if (entry != null && entry.id == id) entry else null72 }73 } finally {74 master.fill(0)75 }76 if (decrypted.isNotEmpty()) repo.applyMergedRemoteEntries(decrypted, emptyList())77 }7879 override suspend fun applyTombstones(tombstones: Map<String, Long>) {80 val cleaned = tombstones81 .filterValues { it > 0L }82 .map { (id, ts) -> Tombstone(id, ts) }83 if (cleaned.isNotEmpty()) repo.applyMergedRemoteEntries(emptyList(), cleaned)84 }8586 private fun requireMasterKey(): ByteArray =87 repo.syncMasterKey() ?: throw VaultLockedException()8889 private fun deriveEntryKey(masterKey: ByteArray, entryId: String): ByteArray {90 91 92 93 94 val info = (entryId + "\u0000" + ENTRY_KEY_INFO_SUFFIX).toByteArray(Charsets.UTF_8)95 val gen = HKDFBytesGenerator(SHA256Digest())96 gen.init(HKDFParameters(masterKey, ByteArray(0), info))97 return ByteArray(32).also { gen.generateBytes(it, 0, it.size) }98 }99100 private fun encryptEntry(key: ByteArray, entry: VaultEntry): String {101 val plaintext = entryJson.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)102 val nonce = ByteArray(12).also(rng::nextBytes)103 val cipher = Cipher.getInstance("AES/GCM/NoPadding")104 cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))105 cipher.updateAAD(ENTRY_AAD)106 val ct = cipher.doFinal(plaintext)107 return b64(nonce + ct)108 }109110 private fun decryptEntryOrNull(key: ByteArray, envelopeB64: String): VaultEntry? = try {111 val bytes = unb64(envelopeB64)112 if (bytes.size < 12 + 16) null else {113 val nonce = bytes.copyOfRange(0, 12)114 val ct = bytes.copyOfRange(12, bytes.size)115 val cipher = Cipher.getInstance("AES/GCM/NoPadding")116 cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))117 cipher.updateAAD(ENTRY_AAD)118 val pt = cipher.doFinal(ct)119 entryJson.decodeFromString(VaultEntry.serializer(), String(pt, Charsets.UTF_8))120 }121 } catch (_: Exception) {122 null123 }124125 private companion object {126 const val ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1"127 val ENTRY_AAD = "peach-sync-entry-v1".toByteArray(Charsets.UTF_8)128 val rng = SecureRandom()129 val entryJson = Json { encodeDefaults = false; ignoreUnknownKeys = true }130 fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b)131 fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)132 }133}134135class VaultLockedException : Exception("vault_locked")