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 if (issuer.trustSource != "direct" && issuer.trustSource != "vouched") return136 val signingKey = issuer.signingPublicKeyHex?.lowercase()?.takeIf { it.length == 64 } ?: return137 val body = verifyDeviceManifest(manifest, hexToPubkey(signingKey)) ?: return138 if (body.issuerDeviceId != issuer.deviceId) return139 val master = requireMasterKey()140 val localKeys = deriveSyncIdentityKeypair(master, ourDeviceId)141142 try {143 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 if (registry.isRevoked(entry.deviceId, entry.longTermPublicKeyHex)) continue167 val existing = registry.get(entry.deviceId)168 val meshRoute = deriveManifestMeshRoute(master, localKeys.publicKey, entry)169 if (existing == null) {170 if (meshRoute == null) continue171 registry.upsert(172 PairedDevice(173 deviceId = entry.deviceId,174 deviceName = entry.deviceName,175 deviceType = entry.deviceType,176 longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),177 signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),178 trustSource = "vouched",179 wsPort = entry.wsPort,180 rendezvousSecretHex = meshRoute.first,181 relayUrl = meshRoute.second,182 pairedAt = entry.pairedAt,183 ),184 )185 pending.remove(entry.deviceId)186 } else {187 deviceKeyRotationAlert(existing, entry, System.currentTimeMillis())?.let { alert ->188 syncAlerts?.record(listOf(alert))189 }190 registry.upsert(191 existing.copy(192 deviceName = existing.deviceName?.takeIf { it.isNotBlank() }193 ?: entry.deviceName,194 deviceType = entry.deviceType,195 longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),196 signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),197 wsPort = existing.wsPort ?: entry.wsPort,198 rendezvousSecretHex =199 if (existing.trustSource == "vouched" && meshRoute != null) {200 meshRoute.first201 } else {202 existing.rendezvousSecretHex203 },204 relayUrl =205 if (existing.trustSource == "vouched" && meshRoute != null) {206 meshRoute.second207 } else {208 existing.relayUrl209 },210 pairedAt = existing.pairedAt,211 trustSource = existing.trustSource ?: "direct",212 ),213 )214 }215 }216 } finally {217 master.fill(0)218 localKeys.secretKey.fill(0)219 }220 }221222 private fun deriveManifestMeshRoute(223 masterKey: ByteArray,224 localPublicKey: ByteArray,225 entry: ManifestDeviceEntry,226 ): Pair<String, String>? = runCatching {227 val relayUrl = Rendezvous.normalizeRelayUrl(entry.relayUrl ?: return null)228 val secret = Rendezvous.deriveMeshSecret(229 masterKey,230 localPublicKey,231 hexToPubkey(entry.longTermPublicKeyHex),232 )233 try {234 Rendezvous.bytesToHex(secret) to relayUrl235 } finally {236 secret.fill(0)237 }238 }.getOrNull()239240 private fun requireMasterKey(): ByteArray =241 repo.syncMasterKey() ?: throw VaultLockedException()242243 private fun deriveEntryKey(masterKey: ByteArray, entryId: String): ByteArray {244 245 246 247 248 val info = (entryId + "\u0000" + ENTRY_KEY_INFO_SUFFIX).toByteArray(Charsets.UTF_8)249 val gen = HKDFBytesGenerator(SHA256Digest())250 gen.init(HKDFParameters(masterKey, ByteArray(0), info))251 return ByteArray(32).also { gen.generateBytes(it, 0, it.size) }252 }253254 private fun encryptEntry(key: ByteArray, entry: VaultEntry): String {255 val plaintext = entryJson.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)256 val nonce = ByteArray(12).also(rng::nextBytes)257 val cipher = Cipher.getInstance("AES/GCM/NoPadding")258 cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))259 cipher.updateAAD(ENTRY_AAD)260 val ct = cipher.doFinal(plaintext)261 return b64(nonce + ct)262 }263264 private fun decryptEntryOrNull(key: ByteArray, envelopeB64: String): VaultEntry? = try {265 val bytes = unb64(envelopeB64)266 if (bytes.size < 12 + 16) null else {267 val nonce = bytes.copyOfRange(0, 12)268 val ct = bytes.copyOfRange(12, bytes.size)269 val cipher = Cipher.getInstance("AES/GCM/NoPadding")270 cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))271 cipher.updateAAD(ENTRY_AAD)272 val pt = cipher.doFinal(ct)273 entryJson.decodeFromString(VaultEntry.serializer(), String(pt, Charsets.UTF_8))274 }275 } catch (_: Exception) {276 null277 }278279 private companion object {280 const val ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1"281 val ENTRY_AAD = "peach-sync-entry-v1".toByteArray(Charsets.UTF_8)282 val rng = SecureRandom()283 val entryJson = Json { encodeDefaults = false; ignoreUnknownKeys = true }284 fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b)285 fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)286 }287}288289class VaultLockedException : Exception("vault_locked")