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(91 entries: Map<String, String>,92 remoteManifest: Map<String, Long>,93 ) {94 val snapshot = requireMasterKeySnapshot()95 val master = snapshot.key96 val decrypted = try {97 entries.mapNotNull { (id, envelope) ->98 99 100 101 var entryKey: ByteArray? = null102 val entry = try {103 entryKey = deriveEntryKey(master, id)104 decryptEntryOrNull(entryKey, envelope)105 } finally {106 entryKey?.let(VaultCrypto::zero)107 }108 109 110 if (entry != null && entry.id == id) entry else null111 }112 } finally {113 VaultCrypto.zero(master)114 }115 if (decrypted.isNotEmpty()) {116 repo.applyMergedRemoteEntries(decrypted, emptyList(), expectedEpoch = snapshot.epoch)117 }118 }119120 override suspend fun applyTombstones(tombstones: Map<String, Long>) {121 val cleaned = tombstones122 .filterValues { it > 0L }123 .map { (id, ts) -> Tombstone(id, ts) }124 if (cleaned.isNotEmpty()) {125 val snapshot = requireMasterKeySnapshot()126 VaultCrypto.zero(snapshot.key)127 repo.applyMergedRemoteEntries(emptyList(), cleaned, expectedEpoch = snapshot.epoch)128 }129 }130131 override fun bindAuthenticatedPeer(peer: PairedDevice) {132 remoteDeviceName = peer.deviceName?.takeIf { it.isNotBlank() }133 ?: peer.deviceId.takeIf { it.isNotBlank() }134 ?: "paired device"135 }136137 override suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? {138 val registry = pairedDevices ?: return null139 val snapshot = requireMasterKeySnapshot()140 val master = snapshot.key141 return try {142 val signing = deriveSyncSigningKeypair(master, ourDeviceId)143 val revoked = registry.listRevocations()144 val revokedIds = revoked.map { it.deviceId }.toSet()145 val devices = registry.getAll()146 .filterNot { revokedIds.contains(it.deviceId) }147 .mapNotNull { it.toManifestEntry() }148 val manifest = buildSignedDeviceManifest(149 ourDeviceId,150 devices,151 signing.privateKey,152 revoked = revoked,153 )154 if (!repo.isSessionEpochCurrent(snapshot.epoch)) throw VaultLockedException()155 manifest156 } finally {157 VaultCrypto.zero(master)158 }159 }160161 override suspend fun applyIncomingDeviceManifest(162 manifest: SignedDeviceManifest,163 authenticatedRemoteStaticPublicKey: ByteArray,164 ) {165 val registry = pairedDevices ?: return166 val pending = pendingVouched ?: return167 if (authenticatedRemoteStaticPublicKey.size != 32) return168 val issuer = registry.getByPubkey(pubkeyToHex(authenticatedRemoteStaticPublicKey)) ?: return169 170 171 172 173 174 175 if (issuer.trustSource != "direct" && issuer.trustSource != "vouched") return176 val signingKey = issuer.signingPublicKeyHex?.lowercase()?.takeIf { it.length == 64 } ?: return177 val body = verifyDeviceManifest(manifest, hexToPubkey(signingKey)) ?: return178 if (body.issuerDeviceId != issuer.deviceId) return179 val snapshot = requireMasterKeySnapshot()180 val master = snapshot.key181 val localKeys = deriveSyncIdentityKeypair(master, ourDeviceId)182183 try {184 if (!repo.isSessionEpochCurrent(snapshot.epoch)) return185 186 187 188 189 val incomingRevocations = body.revoked.filterNot { it.deviceId == ourDeviceId }190 val currentRevocations = registry.listRevocations()191 val knownRevokedIds = currentRevocations.map { it.deviceId }.toSet()192 val newlyRevoked = incomingRevocations.filterNot { knownRevokedIds.contains(it.deviceId) }193 val revokedIds = (knownRevokedIds + incomingRevocations.map { it.deviceId }).toSet()194 val revokedKeys = (currentRevocations + incomingRevocations)195 .map { it.longTermPublicKeyHex.lowercase() }196 .filterNot { it == NO_KEY_SENTINEL }197 .toSet()198 fun revokedInStagedBatch(deviceId: String, longTermKey: String): Boolean {199 return deviceId in revokedIds || longTermKey.lowercase() in revokedKeys200 }201202 val stagedUpserts = LinkedHashMap<String, PairedDevice>()203 val stagedAlerts = ArrayList<SyncAlert>()204 newlyRevoked.forEach { revocation ->205 stagedAlerts += SyncAlert(206 type = SyncAlertType.DEVICE_REVOKED,207 deviceName = issuer.deviceName ?: "paired device",208 entryTitle = revocation.deviceId,209 timestamp = System.currentTimeMillis(),210 )211 }212 for (entry in body.devices) {213 if (entry.deviceId == ourDeviceId || entry.deviceId == issuer.deviceId) {214 continue215 }216 if (revokedInStagedBatch(entry.deviceId, entry.longTermPublicKeyHex)) {217 continue218 }219 val existing = stagedUpserts[entry.deviceId] ?: registry.get(entry.deviceId)220 val meshRoute = deriveManifestMeshRoute(master, localKeys.publicKey, entry)221 if (existing == null) {222 if (meshRoute == null) continue223 stagedUpserts[entry.deviceId] = PairedDevice(224 deviceId = entry.deviceId,225 deviceName = entry.deviceName,226 deviceType = entry.deviceType,227 longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),228 signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),229 trustSource = "vouched",230 wsPort = entry.wsPort,231 rendezvousSecretHex = meshRoute.first,232 relayUrl = meshRoute.second,233 pairedAt = entry.pairedAt,234 )235 } else {236 deviceKeyRotationAlert(existing, entry, System.currentTimeMillis())?.let(stagedAlerts::add)237 stagedUpserts[entry.deviceId] = existing.copy(238 deviceName = existing.deviceName?.takeIf { it.isNotBlank() }239 ?: entry.deviceName,240 deviceType = entry.deviceType,241 longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),242 signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),243 wsPort = existing.wsPort ?: entry.wsPort,244 rendezvousSecretHex =245 if (existing.trustSource == "vouched" && meshRoute != null) {246 meshRoute.first247 } else {248 existing.rendezvousSecretHex249 },250 relayUrl =251 if (existing.trustSource == "vouched" && meshRoute != null) {252 meshRoute.second253 } else {254 existing.relayUrl255 },256 pairedAt = existing.pairedAt,257 trustSource = existing.trustSource ?: "direct",258 )259 }260 }261262 val batch = DeviceManifestRegistryBatch(263 incomingRevocations = incomingRevocations,264 removals = newlyRevoked.map { it.deviceId }.toSet(),265 upserts = stagedUpserts.values.toList(),266 )267 268 269 270 271 val pendingIds = batch.removals + stagedUpserts.keys272 val priorPending = pending.getAll().filter { it.deviceId in pendingIds }273 val committed = repo.withSessionEpochCommit(snapshot.epoch) {274 val pendingRemoved = runCatching { pending.removeBatch(pendingIds) }.getOrDefault(false)275 if (!pendingRemoved) {276 pending.restoreBatch(priorPending)277 false278 } else {279 val registryCommitted = runCatching { registry.applyManifestBatch(batch) }.getOrDefault(false)280 if (registryCommitted) {281 282 283 284 if (stagedAlerts.isNotEmpty()) syncAlerts?.record(stagedAlerts)285 true286 } else {287 pending.restoreBatch(priorPending)288 false289 }290 }291 }292 if (committed != true) return293 } finally {294 VaultCrypto.zero(master)295 VaultCrypto.zero(localKeys.secretKey)296 }297 }298299 private fun deriveManifestMeshRoute(300 masterKey: ByteArray,301 localPublicKey: ByteArray,302 entry: ManifestDeviceEntry,303 ): Pair<String, String>? = runCatching {304 val relayUrl = Rendezvous.normalizeRelayUrl(entry.relayUrl ?: return null)305 val secret = Rendezvous.deriveMeshSecret(306 masterKey,307 localPublicKey,308 hexToPubkey(entry.longTermPublicKeyHex),309 )310 try {311 Rendezvous.bytesToHex(secret) to relayUrl312 } finally {313 secret.fill(0)314 }315 }.getOrNull()316317 private fun requireMasterKeySnapshot(): VaultRepository.SyncMasterKeySnapshot =318 repo.syncMasterKeySnapshot() ?: throw VaultLockedException()319320 internal companion object {321 internal fun deriveEntryKey(masterKey: ByteArray, entryId: String): ByteArray {322 323 324 325 326 val info = (entryId + "\u0000" + ENTRY_KEY_INFO_SUFFIX).toByteArray(Charsets.UTF_8)327 val gen = HKDFBytesGenerator(SHA256Digest())328 gen.init(HKDFParameters(masterKey, ByteArray(0), info))329 return ByteArray(32).also { gen.generateBytes(it, 0, it.size) }330 }331332 internal fun encryptEntry(key: ByteArray, entry: VaultEntry): String {333 334 335 var plaintext: ByteArray? = null336 return try {337 plaintext = entryJson.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)338 val nonce = ByteArray(12).also(rng::nextBytes)339 val cipher = Cipher.getInstance("AES/GCM/NoPadding")340 cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))341 cipher.updateAAD(ENTRY_AAD)342 val ct = cipher.doFinal(plaintext)343 b64(nonce + ct)344 } finally {345 plaintext?.let(VaultCrypto::zero)346 }347 }348349 internal fun encryptEntryWithMasterKey(masterKey: ByteArray, entry: VaultEntry): String {350 val key = deriveEntryKey(masterKey, entry.id)351 return try { encryptEntry(key, entry) } finally { VaultCrypto.zero(key) }352 }353354 internal fun decryptEntryOrNull(key: ByteArray, envelopeB64: String): VaultEntry? {355 var pt: ByteArray? = null356 return try {357 val bytes = unb64(envelopeB64)358 if (bytes.size < 12 + 16) null else {359 val nonce = bytes.copyOfRange(0, 12)360 val ct = bytes.copyOfRange(12, bytes.size)361 val cipher = Cipher.getInstance("AES/GCM/NoPadding")362 cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))363 cipher.updateAAD(ENTRY_AAD)364 pt = cipher.doFinal(ct)365 entryJson.decodeFromString(VaultEntry.serializer(), String(pt, Charsets.UTF_8))366 }367 } catch (_: Exception) {368 null369 } finally {370 371 372 373 pt?.let(VaultCrypto::zero)374 }375 }376377 const val ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1"378 val ENTRY_AAD = "peach-sync-entry-v1".toByteArray(Charsets.UTF_8)379 val rng = SecureRandom()380 val entryJson = Json { encodeDefaults = false; ignoreUnknownKeys = true }381 fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b)382 fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)383 }384}385386class VaultLockedException : Exception("vault_locked")