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) return142 val master = requireMasterKey()143 val localKeys = deriveSyncIdentityKeypair(master, ourDeviceId)144145 try {146 147 148 149 150 val newlyRevoked = registry.mergeRevocations(151 body.revoked.filterNot { it.deviceId == ourDeviceId },152 )153 for (deviceId in newlyRevoked) {154 registry.remove(deviceId)155 pending.remove(deviceId)156 syncAlerts?.record(157 listOf(158 SyncAlert(159 type = SyncAlertType.DEVICE_REVOKED,160 deviceName = issuer.deviceName ?: "paired device",161 entryTitle = deviceId,162 timestamp = System.currentTimeMillis(),163 ),164 ),165 )166 }167 for (entry in body.devices) {168 if (entry.deviceId == ourDeviceId || entry.deviceId == issuer.deviceId) continue169 if (registry.isRevoked(entry.deviceId, entry.longTermPublicKeyHex)) continue170 val existing = registry.get(entry.deviceId)171 val meshRoute = deriveManifestMeshRoute(master, localKeys.publicKey, entry)172 if (existing == null) {173 if (meshRoute == null) continue174 registry.upsert(175 PairedDevice(176 deviceId = entry.deviceId,177 deviceName = entry.deviceName,178 deviceType = entry.deviceType,179 longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),180 signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),181 trustSource = "vouched",182 wsPort = entry.wsPort,183 rendezvousSecretHex = meshRoute.first,184 relayUrl = meshRoute.second,185 pairedAt = entry.pairedAt,186 ),187 )188 pending.remove(entry.deviceId)189 } else {190 deviceKeyRotationAlert(existing, entry, System.currentTimeMillis())?.let { alert ->191 syncAlerts?.record(listOf(alert))192 }193 registry.upsert(194 existing.copy(195 deviceName = existing.deviceName?.takeIf { it.isNotBlank() }196 ?: entry.deviceName,197 deviceType = entry.deviceType,198 longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),199 signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),200 wsPort = existing.wsPort ?: entry.wsPort,201 rendezvousSecretHex =202 if (existing.trustSource == "vouched" && meshRoute != null) {203 meshRoute.first204 } else {205 existing.rendezvousSecretHex206 },207 relayUrl =208 if (existing.trustSource == "vouched" && meshRoute != null) {209 meshRoute.second210 } else {211 existing.relayUrl212 },213 pairedAt = existing.pairedAt,214 trustSource = existing.trustSource ?: "direct",215 ),216 )217 }218 }219 } finally {220 master.fill(0)221 localKeys.secretKey.fill(0)222 }223 }224225 private fun deriveManifestMeshRoute(226 masterKey: ByteArray,227 localPublicKey: ByteArray,228 entry: ManifestDeviceEntry,229 ): Pair<String, String>? = runCatching {230 val relayUrl = Rendezvous.normalizeRelayUrl(entry.relayUrl ?: return null)231 val secret = Rendezvous.deriveMeshSecret(232 masterKey,233 localPublicKey,234 hexToPubkey(entry.longTermPublicKeyHex),235 )236 try {237 Rendezvous.bytesToHex(secret) to relayUrl238 } finally {239 secret.fill(0)240 }241 }.getOrNull()242243 private fun requireMasterKey(): ByteArray =244 repo.syncMasterKey() ?: throw VaultLockedException()245246 private fun deriveEntryKey(masterKey: ByteArray, entryId: String): ByteArray {247 248 249 250 251 val info = (entryId + "\u0000" + ENTRY_KEY_INFO_SUFFIX).toByteArray(Charsets.UTF_8)252 val gen = HKDFBytesGenerator(SHA256Digest())253 gen.init(HKDFParameters(masterKey, ByteArray(0), info))254 return ByteArray(32).also { gen.generateBytes(it, 0, it.size) }255 }256257 private fun encryptEntry(key: ByteArray, entry: VaultEntry): String {258 val plaintext = entryJson.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)259 val nonce = ByteArray(12).also(rng::nextBytes)260 val cipher = Cipher.getInstance("AES/GCM/NoPadding")261 cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))262 cipher.updateAAD(ENTRY_AAD)263 val ct = cipher.doFinal(plaintext)264 return b64(nonce + ct)265 }266267 private fun decryptEntryOrNull(key: ByteArray, envelopeB64: String): VaultEntry? = try {268 val bytes = unb64(envelopeB64)269 if (bytes.size < 12 + 16) null else {270 val nonce = bytes.copyOfRange(0, 12)271 val ct = bytes.copyOfRange(12, bytes.size)272 val cipher = Cipher.getInstance("AES/GCM/NoPadding")273 cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))274 cipher.updateAAD(ENTRY_AAD)275 val pt = cipher.doFinal(ct)276 entryJson.decodeFromString(VaultEntry.serializer(), String(pt, Charsets.UTF_8))277 }278 } catch (_: Exception) {279 null280 }281282 private companion object {283 const val ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1"284 val ENTRY_AAD = "peach-sync-entry-v1".toByteArray(Charsets.UTF_8)285 val rng = SecureRandom()286 val entryJson = Json { encodeDefaults = false; ignoreUnknownKeys = true }287 fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b)288 fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)289 }290}291292class VaultLockedException : Exception("vault_locked")