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 private val pairedDevices: PairedDeviceRegistry? = null,35 private val pendingVouched: PendingVouchedRegistry? = null,36 override val supportsDeviceManifest: Boolean = pairedDevices != null && pendingVouched != null,37) : ChirpSyncVaultAdapter {3839 override suspend fun computeVaultId(): String =40 repo.syncVaultId() ?: throw VaultLockedException()4142 override suspend fun getOurDeviceId(): String = ourDeviceId4344 override suspend fun getLocalVersion(): Long = repo.getVaultVersion()4546 override suspend fun buildEntryManifest(): Map<String, Long> =47 repo.getEntries().associate { it.id to it.modifiedAt }4849 override suspend fun buildTombstoneManifest(): Map<String, Long> =50 repo.getTombstones().associate { it.id to it.deletedAt }5152 override suspend fun getEncryptedEntries(entryIds: List<String>): Map<String, String> {53 val master = requireMasterKey()54 try {55 val out = LinkedHashMap<String, String>()56 for (id in entryIds) {57 val entry = repo.getEntry(id) ?: continue58 out[id] = encryptEntry(deriveEntryKey(master, id), entry)59 }60 return out61 } finally {62 master.fill(0)63 }64 }6566 override suspend fun applyEntries(entries: Map<String, String>) {67 val master = requireMasterKey()68 val decrypted = try {69 entries.mapNotNull { (id, envelope) ->70 val entry = decryptEntryOrNull(deriveEntryKey(master, id), envelope)71 72 73 if (entry != null && entry.id == id) entry else null74 }75 } finally {76 master.fill(0)77 }78 if (decrypted.isNotEmpty()) repo.applyMergedRemoteEntries(decrypted, emptyList())79 }8081 override suspend fun applyTombstones(tombstones: Map<String, Long>) {82 val cleaned = tombstones83 .filterValues { it > 0L }84 .map { (id, ts) -> Tombstone(id, ts) }85 if (cleaned.isNotEmpty()) repo.applyMergedRemoteEntries(emptyList(), cleaned)86 }8788 override suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? {89 val registry = pairedDevices ?: return null90 val master = requireMasterKey()91 return try {92 val signing = deriveSyncSigningKeypair(master, ourDeviceId)93 val devices = registry.getAll().mapNotNull { it.toManifestEntry() }94 buildSignedDeviceManifest(ourDeviceId, devices, signing.privateKey)95 } finally {96 master.fill(0)97 }98 }99100 override suspend fun applyIncomingDeviceManifest(manifest: SignedDeviceManifest) {101 val registry = pairedDevices ?: return102 val pending = pendingVouched ?: return103 val issuerId = manifest.body.issuerDeviceId104 val issuer = registry.get(issuerId) ?: return105 if (issuer.deviceType != "extension") return106 val signingKey = issuer.signingPublicKeyHex?.lowercase()?.takeIf { it.length == 64 } ?: return107 val body = verifyDeviceManifest(manifest, hexToPubkey(signingKey)) ?: return108 if (body.issuerDeviceId != issuer.deviceId) return109110 for (entry in body.devices) {111 if (entry.deviceId == ourDeviceId || entry.deviceId == issuer.deviceId) continue112 val existing = registry.get(entry.deviceId)113 if (existing == null) {114 pending.upsert(115 PendingVouchedDevice(116 deviceId = entry.deviceId,117 deviceName = entry.deviceName,118 deviceType = entry.deviceType,119 longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),120 signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),121 wsPort = entry.wsPort,122 pairedAt = entry.pairedAt,123 receivedAt = System.currentTimeMillis(),124 vouchedByDeviceId = issuer.deviceId,125 ),126 )127 } else {128 registry.upsert(129 existing.copy(130 deviceName = entry.deviceName,131 deviceType = entry.deviceType,132 longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),133 signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),134 wsPort = existing.wsPort ?: entry.wsPort,135 pairedAt = existing.pairedAt,136 trustSource = existing.trustSource ?: "direct",137 ),138 )139 }140 }141 }142143 private fun requireMasterKey(): ByteArray =144 repo.syncMasterKey() ?: throw VaultLockedException()145146 private fun deriveEntryKey(masterKey: ByteArray, entryId: String): ByteArray {147 148 149 150 151 val info = (entryId + "\u0000" + ENTRY_KEY_INFO_SUFFIX).toByteArray(Charsets.UTF_8)152 val gen = HKDFBytesGenerator(SHA256Digest())153 gen.init(HKDFParameters(masterKey, ByteArray(0), info))154 return ByteArray(32).also { gen.generateBytes(it, 0, it.size) }155 }156157 private fun encryptEntry(key: ByteArray, entry: VaultEntry): String {158 val plaintext = entryJson.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)159 val nonce = ByteArray(12).also(rng::nextBytes)160 val cipher = Cipher.getInstance("AES/GCM/NoPadding")161 cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))162 cipher.updateAAD(ENTRY_AAD)163 val ct = cipher.doFinal(plaintext)164 return b64(nonce + ct)165 }166167 private fun decryptEntryOrNull(key: ByteArray, envelopeB64: String): VaultEntry? = try {168 val bytes = unb64(envelopeB64)169 if (bytes.size < 12 + 16) null else {170 val nonce = bytes.copyOfRange(0, 12)171 val ct = bytes.copyOfRange(12, bytes.size)172 val cipher = Cipher.getInstance("AES/GCM/NoPadding")173 cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))174 cipher.updateAAD(ENTRY_AAD)175 val pt = cipher.doFinal(ct)176 entryJson.decodeFromString(VaultEntry.serializer(), String(pt, Charsets.UTF_8))177 }178 } catch (_: Exception) {179 null180 }181182 private companion object {183 const val ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1"184 val ENTRY_AAD = "peach-sync-entry-v1".toByteArray(Charsets.UTF_8)185 val rng = SecureRandom()186 val entryJson = Json { encodeDefaults = false; ignoreUnknownKeys = true }187 fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b)188 fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)189 }190}191192class VaultLockedException : Exception("vault_locked")