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 private val syncAlerts: SyncAlertRecorder? = null,37 private var remoteDeviceName: String = "paired device",38 override val supportsDeviceManifest: Boolean = pairedDevices != null && pendingVouched != null,39) : SyncVaultAdapter {4041 override suspend fun computeVaultId(): String =42 repo.syncVaultId() ?: throw VaultLockedException()4344 override suspend fun getOurDeviceId(): String = ourDeviceId4546 override suspend fun getLocalVersion(): Long = repo.getVaultVersion()4748 override suspend fun buildEntryManifest(): Map<String, Long> =49 repo.getEntries().associate { it.id to it.modifiedAt }5051 override suspend fun buildTombstoneManifest(): Map<String, Long> =52 repo.getTombstones().associate { it.id to it.deletedAt }5354 override suspend fun getEncryptedEntries(entryIds: List<String>): Map<String, String> {55 val master = requireMasterKey()56 try {57 val out = LinkedHashMap<String, String>()58 for (id in entryIds) {59 val entry = repo.getEntry(id) ?: continue60 out[id] = encryptEntry(deriveEntryKey(master, id), entry)61 }62 return out63 } finally {64 master.fill(0)65 }66 }6768 override suspend fun applyEntries(entries: Map<String, String>) {69 val master = requireMasterKey()70 val decrypted = try {71 entries.mapNotNull { (id, envelope) ->72 val entry = decryptEntryOrNull(deriveEntryKey(master, id), envelope)73 74 75 if (entry != null && entry.id == id) entry else null76 }77 } finally {78 master.fill(0)79 }80 if (decrypted.isNotEmpty()) {81 repo.applyMergedRemoteEntries(decrypted, emptyList())82 }83 }8485 override suspend fun applyTombstones(tombstones: Map<String, Long>) {86 val cleaned = tombstones87 .filterValues { it > 0L }88 .map { (id, ts) -> Tombstone(id, ts) }89 if (cleaned.isNotEmpty()) {90 repo.applyMergedRemoteEntries(emptyList(), cleaned)91 }92 }9394 override fun bindAuthenticatedPeer(peer: PairedDevice) {95 remoteDeviceName = peer.deviceName?.takeIf { it.isNotBlank() }96 ?: peer.deviceId.takeIf { it.isNotBlank() }97 ?: "paired device"98 }99100 override suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? {101 val registry = pairedDevices ?: return null102 val master = requireMasterKey()103 return try {104 val signing = deriveSyncSigningKeypair(master, ourDeviceId)105 val revoked = registry.listRevocations()106 val revokedIds = revoked.map { it.deviceId }.toSet()107 val devices = registry.getAll()108 .filterNot { revokedIds.contains(it.deviceId) }109 .mapNotNull { it.toManifestEntry() }110 buildSignedDeviceManifest(111 ourDeviceId,112 devices,113 signing.privateKey,114 revoked = revoked,115 )116 } finally {117 master.fill(0)118 }119 }120121 override suspend fun applyIncomingDeviceManifest(122 manifest: SignedDeviceManifest,123 authenticatedRemoteStaticPublicKey: ByteArray,124 ) {125 val registry = pairedDevices ?: return126 val pending = pendingVouched ?: return127 if (authenticatedRemoteStaticPublicKey.size != 32) return128 val issuer = registry.getByPubkey(pubkeyToHex(authenticatedRemoteStaticPublicKey)) ?: return129 130 131 132 133 134 135 136 137 138 if (issuer.trustSource != "direct") return139 val signingKey = issuer.signingPublicKeyHex?.lowercase()?.takeIf { it.length == 64 } ?: return140 val body = verifyDeviceManifest(manifest, hexToPubkey(signingKey)) ?: return141 if (body.issuerDeviceId != issuer.deviceId) return142143 144 145 146 147 val newlyRevoked = registry.mergeRevocations(148 body.revoked.filterNot { it.deviceId == ourDeviceId },149 )150 for (deviceId in newlyRevoked) {151 registry.remove(deviceId)152 pending.remove(deviceId)153 syncAlerts?.record(154 listOf(155 SyncAlert(156 type = SyncAlertType.DEVICE_REVOKED,157 deviceName = issuer.deviceName ?: "paired device",158 entryTitle = deviceId,159 timestamp = System.currentTimeMillis(),160 ),161 ),162 )163 }164 for (entry in body.devices) {165 if (entry.deviceId == ourDeviceId || entry.deviceId == issuer.deviceId) continue166 167 168 169 170 171 if (registry.isRevoked(entry.deviceId, entry.longTermPublicKeyHex)) continue172 val existing = registry.get(entry.deviceId)173 if (existing == null) {174 pending.upsert(175 PendingVouchedDevice(176 deviceId = entry.deviceId,177 deviceName = entry.deviceName,178 deviceType = entry.deviceType,179 longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),180 signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),181 wsPort = entry.wsPort,182 pairedAt = entry.pairedAt,183 receivedAt = System.currentTimeMillis(),184 vouchedByDeviceId = issuer.deviceId,185 ),186 )187 } else {188 deviceKeyRotationAlert(existing, entry, System.currentTimeMillis())?.let { alert ->189 syncAlerts?.record(listOf(alert))190 }191 192 193 194 registry.upsert(195 existing.copy(196 deviceName = existing.deviceName?.takeIf { it.isNotBlank() }197 ?: entry.deviceName,198 deviceType = entry.deviceType,199 longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),200 signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),201 wsPort = existing.wsPort ?: entry.wsPort,202 pairedAt = existing.pairedAt,203 trustSource = existing.trustSource ?: "direct",204 ),205 )206 }207 }208 }209210 private fun requireMasterKey(): ByteArray =211 repo.syncMasterKey() ?: throw VaultLockedException()212213 private fun deriveEntryKey(masterKey: ByteArray, entryId: String): ByteArray {214 215 216 217 218 val info = (entryId + "\u0000" + ENTRY_KEY_INFO_SUFFIX).toByteArray(Charsets.UTF_8)219 val gen = HKDFBytesGenerator(SHA256Digest())220 gen.init(HKDFParameters(masterKey, ByteArray(0), info))221 return ByteArray(32).also { gen.generateBytes(it, 0, it.size) }222 }223224 private fun encryptEntry(key: ByteArray, entry: VaultEntry): String {225 val plaintext = entryJson.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)226 val nonce = ByteArray(12).also(rng::nextBytes)227 val cipher = Cipher.getInstance("AES/GCM/NoPadding")228 cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))229 cipher.updateAAD(ENTRY_AAD)230 val ct = cipher.doFinal(plaintext)231 return b64(nonce + ct)232 }233234 private fun decryptEntryOrNull(key: ByteArray, envelopeB64: String): VaultEntry? = try {235 val bytes = unb64(envelopeB64)236 if (bytes.size < 12 + 16) null else {237 val nonce = bytes.copyOfRange(0, 12)238 val ct = bytes.copyOfRange(12, bytes.size)239 val cipher = Cipher.getInstance("AES/GCM/NoPadding")240 cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))241 cipher.updateAAD(ENTRY_AAD)242 val pt = cipher.doFinal(ct)243 entryJson.decodeFromString(VaultEntry.serializer(), String(pt, Charsets.UTF_8))244 }245 } catch (_: Exception) {246 null247 }248249 private companion object {250 const val ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1"251 val ENTRY_AAD = "peach-sync-entry-v1".toByteArray(Charsets.UTF_8)252 val rng = SecureRandom()253 val entryJson = Json { encodeDefaults = false; ignoreUnknownKeys = true }254 fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b)255 fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)256 }257}258259class VaultLockedException : Exception("vault_locked")