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 val snapshot = requireMasterKeySnapshot()51 VaultCrypto.zero(snapshot.key)52 val manifest = repo.getEntries().associate { it.id to it.modifiedAt }53 if (!repo.isSessionEpochCurrent(snapshot.epoch)) throw VaultLockedException()54 return manifest55 }5657 override suspend fun buildTombstoneManifest(): Map<String, Long> {58 val snapshot = requireMasterKeySnapshot()59 VaultCrypto.zero(snapshot.key)60 val manifest = repo.getTombstones().associate { it.id to it.deletedAt }61 if (!repo.isSessionEpochCurrent(snapshot.epoch)) throw VaultLockedException()62 return manifest63 }6465 override suspend fun getEncryptedEntries(entryIds: List<String>): Map<String, String> {66 val snapshot = requireMasterKeySnapshot()67 val master = snapshot.key68 try {69 val out = LinkedHashMap<String, String>()70 for (id in entryIds) {71 val entry = repo.getEntry(id) ?: continue72 73 74 75 var entryKey: ByteArray? = null76 try {77 entryKey = deriveEntryKey(master, id)78 out[id] = encryptEntry(entryKey, entry)79 } finally {80 entryKey?.let(VaultCrypto::zero)81 }82 }83 if (!repo.isSessionEpochCurrent(snapshot.epoch)) throw VaultLockedException()84 return out85 } finally {86 VaultCrypto.zero(master)87 }88 }8990 override suspend fun applyEntries(entries: Map<String, String>) {91 val snapshot = requireMasterKeySnapshot()92 val master = snapshot.key93 val decrypted = try {94 entries.mapNotNull { (id, envelope) ->95 96 97 98 var entryKey: ByteArray? = null99 val entry = try {100 entryKey = deriveEntryKey(master, id)101 decryptEntryOrNull(entryKey, envelope)102 } finally {103 entryKey?.let(VaultCrypto::zero)104 }105 106 107 if (entry != null && entry.id == id) entry else null108 }109 } finally {110 VaultCrypto.zero(master)111 }112 if (decrypted.isNotEmpty()) {113 repo.applyMergedRemoteEntries(decrypted, emptyList(), expectedEpoch = snapshot.epoch)114 }115 }116117 override suspend fun applyTombstones(tombstones: Map<String, Long>) {118 val cleaned = tombstones119 .filterValues { it > 0L }120 .map { (id, ts) -> Tombstone(id, ts) }121 if (cleaned.isNotEmpty()) {122 val snapshot = requireMasterKeySnapshot()123 VaultCrypto.zero(snapshot.key)124 repo.applyMergedRemoteEntries(emptyList(), cleaned, expectedEpoch = snapshot.epoch)125 }126 }127128 override fun bindAuthenticatedPeer(peer: PairedDevice) {129 remoteDeviceName = peer.deviceName?.takeIf { it.isNotBlank() }130 ?: peer.deviceId.takeIf { it.isNotBlank() }131 ?: "paired device"132 }133134 override suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? {135 val registry = pairedDevices ?: return null136 val snapshot = requireMasterKeySnapshot()137 val master = snapshot.key138 return try {139 val signing = deriveSyncSigningKeypair(master, ourDeviceId)140 val revoked = registry.listRevocations()141 val revokedIds = revoked.map { it.deviceId }.toSet()142 val devices = registry.getAll()143 .filterNot { revokedIds.contains(it.deviceId) }144 .mapNotNull { it.toManifestEntry() }145 val manifest = buildSignedDeviceManifest(146 ourDeviceId,147 devices,148 signing.privateKey,149 revoked = revoked,150 )151 if (!repo.isSessionEpochCurrent(snapshot.epoch)) throw VaultLockedException()152 manifest153 } finally {154 VaultCrypto.zero(master)155 }156 }157158 override suspend fun applyIncomingDeviceManifest(159 manifest: SignedDeviceManifest,160 authenticatedRemoteStaticPublicKey: ByteArray,161 ) {162 val registry = pairedDevices ?: return163 val pending = pendingVouched ?: return164 if (authenticatedRemoteStaticPublicKey.size != 32) return165 val issuer = registry.getByPubkey(pubkeyToHex(authenticatedRemoteStaticPublicKey)) ?: return166 167 168 169 170 171 172 if (issuer.trustSource != "direct" && issuer.trustSource != "vouched") return173 val signingKey = issuer.signingPublicKeyHex?.lowercase()?.takeIf { it.length == 64 } ?: return174 val body = verifyDeviceManifest(manifest, hexToPubkey(signingKey)) ?: return175 if (body.issuerDeviceId != issuer.deviceId) return176 val snapshot = requireMasterKeySnapshot()177 val master = snapshot.key178 val localKeys = deriveSyncIdentityKeypair(master, ourDeviceId)179180 try {181 if (!repo.isSessionEpochCurrent(snapshot.epoch)) return182 183 184 185 186 val incomingRevocations = body.revoked.filterNot { it.deviceId == ourDeviceId }187 val currentRevocations = registry.listRevocations()188 val knownRevokedIds = currentRevocations.map { it.deviceId }.toSet()189 val newlyRevoked = incomingRevocations.filterNot { knownRevokedIds.contains(it.deviceId) }190 val revokedIds = (knownRevokedIds + incomingRevocations.map { it.deviceId }).toSet()191 val revokedKeys = (currentRevocations + incomingRevocations)192 .map { it.longTermPublicKeyHex.lowercase() }193 .filterNot { it == NO_KEY_SENTINEL }194 .toSet()195 fun revokedInStagedBatch(deviceId: String, longTermKey: String): Boolean {196 return deviceId in revokedIds || longTermKey.lowercase() in revokedKeys197 }198199 val stagedUpserts = LinkedHashMap<String, PairedDevice>()200 val stagedAlerts = ArrayList<SyncAlert>()201 newlyRevoked.forEach { revocation ->202 stagedAlerts += SyncAlert(203 type = SyncAlertType.DEVICE_REVOKED,204 deviceName = issuer.deviceName ?: "paired device",205 entryTitle = revocation.deviceId,206 timestamp = System.currentTimeMillis(),207 )208 }209 for (entry in body.devices) {210 if (entry.deviceId == ourDeviceId || entry.deviceId == issuer.deviceId) {211 continue212 }213 if (revokedInStagedBatch(entry.deviceId, entry.longTermPublicKeyHex)) {214 continue215 }216 val existing = stagedUpserts[entry.deviceId] ?: registry.get(entry.deviceId)217 val meshRoute = deriveManifestMeshRoute(master, localKeys.publicKey, entry)218 if (existing == null) {219 if (meshRoute == null) continue220 stagedUpserts[entry.deviceId] = PairedDevice(221 deviceId = entry.deviceId,222 deviceName = entry.deviceName,223 deviceType = entry.deviceType,224 longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),225 signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),226 trustSource = "vouched",227 wsPort = entry.wsPort,228 rendezvousSecretHex = meshRoute.first,229 relayUrl = meshRoute.second,230 pairedAt = entry.pairedAt,231 )232 } else {233 deviceKeyRotationAlert(existing, entry, System.currentTimeMillis())?.let(stagedAlerts::add)234 stagedUpserts[entry.deviceId] = existing.copy(235 deviceName = existing.deviceName?.takeIf { it.isNotBlank() }236 ?: entry.deviceName,237 deviceType = entry.deviceType,238 longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),239 signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),240 wsPort = existing.wsPort ?: entry.wsPort,241 rendezvousSecretHex =242 if (existing.trustSource == "vouched" && meshRoute != null) {243 meshRoute.first244 } else {245 existing.rendezvousSecretHex246 },247 relayUrl =248 if (existing.trustSource == "vouched" && meshRoute != null) {249 meshRoute.second250 } else {251 existing.relayUrl252 },253 pairedAt = existing.pairedAt,254 trustSource = existing.trustSource ?: "direct",255 )256 }257 }258259 val batch = DeviceManifestRegistryBatch(260 incomingRevocations = incomingRevocations,261 removals = newlyRevoked.map { it.deviceId }.toSet(),262 upserts = stagedUpserts.values.toList(),263 )264 265 266 267 268 val pendingIds = batch.removals + stagedUpserts.keys269 val priorPending = pending.getAll().filter { it.deviceId in pendingIds }270 val committed = repo.withSessionEpochCommit(snapshot.epoch) {271 val pendingRemoved = runCatching { pending.removeBatch(pendingIds) }.getOrDefault(false)272 if (!pendingRemoved) {273 pending.restoreBatch(priorPending)274 false275 } else {276 val registryCommitted = runCatching { registry.applyManifestBatch(batch) }.getOrDefault(false)277 if (registryCommitted) {278 279 280 281 if (stagedAlerts.isNotEmpty()) syncAlerts?.record(stagedAlerts)282 true283 } else {284 pending.restoreBatch(priorPending)285 false286 }287 }288 }289 if (committed != true) return290 } finally {291 VaultCrypto.zero(master)292 VaultCrypto.zero(localKeys.secretKey)293 }294 }295296 private fun deriveManifestMeshRoute(297 masterKey: ByteArray,298 localPublicKey: ByteArray,299 entry: ManifestDeviceEntry,300 ): Pair<String, String>? = runCatching {301 val relayUrl = Rendezvous.normalizeRelayUrl(entry.relayUrl ?: return null)302 val secret = Rendezvous.deriveMeshSecret(303 masterKey,304 localPublicKey,305 hexToPubkey(entry.longTermPublicKeyHex),306 )307 try {308 Rendezvous.bytesToHex(secret) to relayUrl309 } finally {310 secret.fill(0)311 }312 }.getOrNull()313314 private fun requireMasterKeySnapshot(): VaultRepository.SyncMasterKeySnapshot =315 repo.syncMasterKeySnapshot() ?: throw VaultLockedException()316317318 private fun deriveEntryKey(masterKey: ByteArray, entryId: String): ByteArray {319 320 321 322 323 val info = (entryId + "\u0000" + ENTRY_KEY_INFO_SUFFIX).toByteArray(Charsets.UTF_8)324 val gen = HKDFBytesGenerator(SHA256Digest())325 gen.init(HKDFParameters(masterKey, ByteArray(0), info))326 return ByteArray(32).also { gen.generateBytes(it, 0, it.size) }327 }328329 private fun encryptEntry(key: ByteArray, entry: VaultEntry): String {330 331 332 var plaintext: ByteArray? = null333 return try {334 plaintext = entryJson.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)335 val nonce = ByteArray(12).also(rng::nextBytes)336 val cipher = Cipher.getInstance("AES/GCM/NoPadding")337 cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))338 cipher.updateAAD(ENTRY_AAD)339 val ct = cipher.doFinal(plaintext)340 b64(nonce + ct)341 } finally {342 plaintext?.let(VaultCrypto::zero)343 }344 }345346 private fun decryptEntryOrNull(key: ByteArray, envelopeB64: String): VaultEntry? {347 var pt: ByteArray? = null348 return try {349 val bytes = unb64(envelopeB64)350 if (bytes.size < 12 + 16) null else {351 val nonce = bytes.copyOfRange(0, 12)352 val ct = bytes.copyOfRange(12, bytes.size)353 val cipher = Cipher.getInstance("AES/GCM/NoPadding")354 cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))355 cipher.updateAAD(ENTRY_AAD)356 pt = cipher.doFinal(ct)357 entryJson.decodeFromString(VaultEntry.serializer(), String(pt, Charsets.UTF_8))358 }359 } catch (_: Exception) {360 null361 } finally {362 363 364 365 pt?.let(VaultCrypto::zero)366 }367 }368369 private companion object {370 const val ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1"371 val ENTRY_AAD = "peach-sync-entry-v1".toByteArray(Charsets.UTF_8)372 val rng = SecureRandom()373 val entryJson = Json { encodeDefaults = false; ignoreUnknownKeys = true }374 fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b)375 fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)376 }377}378379class VaultLockedException : Exception("vault_locked")