1package com.peachpasswords.sync23import com.peachpasswords.vault.Tombstone4import com.peachpasswords.vault.VaultCrypto5import com.peachpasswords.vault.VaultEntry6import com.peachpasswords.vault.VaultRepository7import kotlinx.serialization.json.Json8import org.bouncycastle.crypto.digests.SHA256Digest9import org.bouncycastle.crypto.generators.HKDFBytesGenerator10import org.bouncycastle.crypto.params.HKDFParameters11import java.security.SecureRandom12import java.util.Base6413import javax.crypto.Cipher14import javax.crypto.spec.GCMParameterSpec15import javax.crypto.spec.SecretKeySpec1617181920212223242526272829303132class VaultSyncAdapter(33 private val repo: VaultRepository,34 private val ourDeviceId: String,35 private val pairedDevices: PairedDeviceRegistry? = null,36 private val pendingVouched: PendingVouchedRegistry? = null,37 private val syncAlerts: SyncAlertRecorder? = null,38 private var remoteDeviceName: String = "paired device",39 override val supportsDeviceManifest: Boolean = pairedDevices != null && pendingVouched != null,40) : SyncVaultAdapter {4142 override suspend fun computeVaultId(): String =43 repo.syncVaultId() ?: throw VaultLockedException()4445 override suspend fun getOurDeviceId(): String = ourDeviceId4647 override suspend fun getLocalVersion(): Long = repo.getVaultVersion()4849 override suspend fun buildEntryManifest(): Map<String, Long> =50 repo.getEntries().associate { it.id to it.modifiedAt }5152 override suspend fun buildTombstoneManifest(): Map<String, Long> =53 repo.getTombstones().associate { it.id to it.deletedAt }5455 override suspend fun getEncryptedEntries(entryIds: List<String>): Map<String, String> {56 val master = requireMasterKey()57 try {58 val out = LinkedHashMap<String, String>()59 for (id in entryIds) {60 val entry = repo.getEntry(id) ?: continue61 62 63 64 var entryKey: ByteArray? = null65 try {66 entryKey = deriveEntryKey(master, id)67 out[id] = encryptEntry(entryKey, entry)68 } finally {69 entryKey?.let(VaultCrypto::zero)70 }71 }72 return out73 } finally {74 master.fill(0)75 }76 }7778 override suspend fun applyEntries(entries: Map<String, String>) {79 val master = requireMasterKey()80 val decrypted = try {81 entries.mapNotNull { (id, envelope) ->82 83 84 85 var entryKey: ByteArray? = null86 val entry = try {87 entryKey = deriveEntryKey(master, id)88 decryptEntryOrNull(entryKey, envelope)89 } finally {90 entryKey?.let(VaultCrypto::zero)91 }92 93 94 if (entry != null && entry.id == id) entry else null95 }96 } finally {97 master.fill(0)98 }99 if (decrypted.isNotEmpty()) {100 repo.applyMergedRemoteEntries(decrypted, emptyList())101 }102 }103104 override suspend fun applyTombstones(tombstones: Map<String, Long>) {105 val cleaned = tombstones106 .filterValues { it > 0L }107 .map { (id, ts) -> Tombstone(id, ts) }108 if (cleaned.isNotEmpty()) {109 repo.applyMergedRemoteEntries(emptyList(), cleaned)110 }111 }112113 override fun bindAuthenticatedPeer(peer: PairedDevice) {114 remoteDeviceName = peer.deviceName?.takeIf { it.isNotBlank() }115 ?: peer.deviceId.takeIf { it.isNotBlank() }116 ?: "paired device"117 }118119 override suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? {120 val registry = pairedDevices ?: return null121 val master = requireMasterKey()122 return try {123 val signing = deriveSyncSigningKeypair(master, ourDeviceId)124 val revoked = registry.listRevocations()125 val revokedIds = revoked.map { it.deviceId }.toSet()126 val devices = registry.getAll()127 .filterNot { revokedIds.contains(it.deviceId) }128 .mapNotNull { it.toManifestEntry() }129 buildSignedDeviceManifest(130 ourDeviceId,131 devices,132 signing.privateKey,133 revoked = revoked,134 )135 } finally {136 master.fill(0)137 }138 }139140 override suspend fun applyIncomingDeviceManifest(141 manifest: SignedDeviceManifest,142 authenticatedRemoteStaticPublicKey: ByteArray,143 ) {144 val registry = pairedDevices ?: return145 val pending = pendingVouched ?: return146 if (authenticatedRemoteStaticPublicKey.size != 32) return147 val issuer = registry.getByPubkey(pubkeyToHex(authenticatedRemoteStaticPublicKey)) ?: return148 149 150 151 152 153 154 if (issuer.trustSource != "direct" && issuer.trustSource != "vouched") return155 val signingKey = issuer.signingPublicKeyHex?.lowercase()?.takeIf { it.length == 64 } ?: return156 val body = verifyDeviceManifest(manifest, hexToPubkey(signingKey)) ?: return157 if (body.issuerDeviceId != issuer.deviceId) return158 val master = requireMasterKey()159 val localKeys = deriveSyncIdentityKeypair(master, ourDeviceId)160161 try {162 163 164 165 166 val newlyRevoked = registry.mergeRevocations(167 body.revoked.filterNot { it.deviceId == ourDeviceId },168 )169 for (deviceId in newlyRevoked) {170 registry.remove(deviceId)171 pending.remove(deviceId)172 syncAlerts?.record(173 listOf(174 SyncAlert(175 type = SyncAlertType.DEVICE_REVOKED,176 deviceName = issuer.deviceName ?: "paired device",177 entryTitle = deviceId,178 timestamp = System.currentTimeMillis(),179 ),180 ),181 )182 }183 for (entry in body.devices) {184 if (entry.deviceId == ourDeviceId || entry.deviceId == issuer.deviceId) continue185 if (registry.isRevoked(entry.deviceId, entry.longTermPublicKeyHex)) continue186 val existing = registry.get(entry.deviceId)187 val meshRoute = deriveManifestMeshRoute(master, localKeys.publicKey, entry)188 if (existing == null) {189 if (meshRoute == null) continue190 registry.upsert(191 PairedDevice(192 deviceId = entry.deviceId,193 deviceName = entry.deviceName,194 deviceType = entry.deviceType,195 longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),196 signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),197 trustSource = "vouched",198 wsPort = entry.wsPort,199 rendezvousSecretHex = meshRoute.first,200 relayUrl = meshRoute.second,201 pairedAt = entry.pairedAt,202 ),203 )204 pending.remove(entry.deviceId)205 } else {206 deviceKeyRotationAlert(existing, entry, System.currentTimeMillis())?.let { alert ->207 syncAlerts?.record(listOf(alert))208 }209 registry.upsert(210 existing.copy(211 deviceName = existing.deviceName?.takeIf { it.isNotBlank() }212 ?: entry.deviceName,213 deviceType = entry.deviceType,214 longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),215 signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),216 wsPort = existing.wsPort ?: entry.wsPort,217 rendezvousSecretHex =218 if (existing.trustSource == "vouched" && meshRoute != null) {219 meshRoute.first220 } else {221 existing.rendezvousSecretHex222 },223 relayUrl =224 if (existing.trustSource == "vouched" && meshRoute != null) {225 meshRoute.second226 } else {227 existing.relayUrl228 },229 pairedAt = existing.pairedAt,230 trustSource = existing.trustSource ?: "direct",231 ),232 )233 }234 }235 } finally {236 master.fill(0)237 localKeys.secretKey.fill(0)238 }239 }240241 private fun deriveManifestMeshRoute(242 masterKey: ByteArray,243 localPublicKey: ByteArray,244 entry: ManifestDeviceEntry,245 ): Pair<String, String>? = runCatching {246 val relayUrl = Rendezvous.normalizeRelayUrl(entry.relayUrl ?: return null)247 val secret = Rendezvous.deriveMeshSecret(248 masterKey,249 localPublicKey,250 hexToPubkey(entry.longTermPublicKeyHex),251 )252 try {253 Rendezvous.bytesToHex(secret) to relayUrl254 } finally {255 secret.fill(0)256 }257 }.getOrNull()258259 private fun requireMasterKey(): ByteArray =260 repo.syncMasterKey() ?: throw VaultLockedException()261262 private fun deriveEntryKey(masterKey: ByteArray, entryId: String): ByteArray {263 264 265 266 267 val info = (entryId + "\u0000" + ENTRY_KEY_INFO_SUFFIX).toByteArray(Charsets.UTF_8)268 val gen = HKDFBytesGenerator(SHA256Digest())269 gen.init(HKDFParameters(masterKey, ByteArray(0), info))270 return ByteArray(32).also { gen.generateBytes(it, 0, it.size) }271 }272273 private fun encryptEntry(key: ByteArray, entry: VaultEntry): String {274 275 276 var plaintext: ByteArray? = null277 return try {278 plaintext = entryJson.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)279 val nonce = ByteArray(12).also(rng::nextBytes)280 val cipher = Cipher.getInstance("AES/GCM/NoPadding")281 cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))282 cipher.updateAAD(ENTRY_AAD)283 val ct = cipher.doFinal(plaintext)284 b64(nonce + ct)285 } finally {286 plaintext?.let(VaultCrypto::zero)287 }288 }289290 private fun decryptEntryOrNull(key: ByteArray, envelopeB64: String): VaultEntry? {291 var pt: ByteArray? = null292 return try {293 val bytes = unb64(envelopeB64)294 if (bytes.size < 12 + 16) null else {295 val nonce = bytes.copyOfRange(0, 12)296 val ct = bytes.copyOfRange(12, bytes.size)297 val cipher = Cipher.getInstance("AES/GCM/NoPadding")298 cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))299 cipher.updateAAD(ENTRY_AAD)300 pt = cipher.doFinal(ct)301 entryJson.decodeFromString(VaultEntry.serializer(), String(pt, Charsets.UTF_8))302 }303 } catch (_: Exception) {304 null305 } finally {306 307 308 309 pt?.let(VaultCrypto::zero)310 }311 }312313 private companion object {314 const val ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1"315 val ENTRY_AAD = "peach-sync-entry-v1".toByteArray(Charsets.UTF_8)316 val rng = SecureRandom()317 val entryJson = Json { encodeDefaults = false; ignoreUnknownKeys = true }318 fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b)319 fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)320 }321}322323class VaultLockedException : Exception("vault_locked")