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 private val localCredentials: LocalIdentityCredentials? = null,41) : SyncVaultAdapter {4243 override suspend fun computeVaultId(): String =44 repo.syncVaultId() ?: throw VaultLockedException()4546 override suspend fun getOurDeviceId(): String = ourDeviceId4748 override suspend fun getLocalVersion(): Long = repo.getVaultVersion()4950 override suspend fun buildEntryManifest(): Map<String, Long> {51 val snapshot = requireMasterKeySnapshot()52 VaultCrypto.zero(snapshot.key)53 val manifest = repo.getEntries().associate { it.id to it.modifiedAt }54 if (!repo.isSessionEpochCurrent(snapshot.epoch)) throw VaultLockedException()55 return manifest56 }5758 override suspend fun buildTombstoneManifest(): Map<String, Long> {59 val snapshot = requireMasterKeySnapshot()60 VaultCrypto.zero(snapshot.key)61 val manifest = repo.getTombstones().associate { it.id to it.deletedAt }62 if (!repo.isSessionEpochCurrent(snapshot.epoch)) throw VaultLockedException()63 return manifest64 }6566 override suspend fun getEncryptedEntries(entryIds: List<String>): Map<String, String> {67 val snapshot = requireMasterKeySnapshot()68 val master = snapshot.key69 try {70 val out = LinkedHashMap<String, String>()71 for (id in entryIds) {72 val entry = repo.getEntry(id) ?: continue73 74 75 76 var entryKey: ByteArray? = null77 try {78 entryKey = deriveEntryKey(master, id)79 out[id] = encryptEntry(entryKey, entry)80 } finally {81 entryKey?.let(VaultCrypto::zero)82 }83 }84 if (!repo.isSessionEpochCurrent(snapshot.epoch)) throw VaultLockedException()85 return out86 } finally {87 VaultCrypto.zero(master)88 }89 }9091 override suspend fun applyEntries(92 entries: Map<String, String>,93 remoteManifest: Map<String, Long>,94 ) {95 val snapshot = requireMasterKeySnapshot()96 val master = snapshot.key97 val decrypted = try {98 entries.mapNotNull { (id, envelope) ->99 100 101 102 var entryKey: ByteArray? = null103 val entry = try {104 entryKey = deriveEntryKey(master, id)105 decryptEntryOrNull(entryKey, envelope)106 } finally {107 entryKey?.let(VaultCrypto::zero)108 }109 110 111 if (entry != null && entry.id == id) entry else null112 }113 } finally {114 VaultCrypto.zero(master)115 }116 if (decrypted.isNotEmpty()) {117 repo.applyMergedRemoteEntries(decrypted, emptyList(), expectedEpoch = snapshot.epoch)118 }119 }120121 override suspend fun applyTombstones(tombstones: Map<String, Long>) {122 val cleaned = tombstones123 .filterValues { it > 0L }124 .map { (id, ts) -> Tombstone(id, ts) }125 if (cleaned.isNotEmpty()) {126 val snapshot = requireMasterKeySnapshot()127 VaultCrypto.zero(snapshot.key)128 repo.applyMergedRemoteEntries(emptyList(), cleaned, expectedEpoch = snapshot.epoch)129 }130 }131132 override fun bindAuthenticatedPeer(peer: PairedDevice) {133 remoteDeviceName = peer.deviceName?.takeIf { it.isNotBlank() }134 ?: peer.deviceId.takeIf { it.isNotBlank() }135 ?: "paired device"136 }137138 override suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? {139 val registry = pairedDevices ?: return null140 val signing = localCredentials?.let(::localSigningKeypair)141 ?: throw IllegalStateException("identity_migration_required")142 val revoked = registry.listRevocations()143 val revokedIds = revoked.map { it.deviceId }.toSet()144 val devices = registry.getAll()145 .filterNot { revokedIds.contains(it.deviceId) }146 .mapNotNull { it.toManifestEntry() }147 .map { it.copy(vouchDepth = if (it.deviceId == ourDeviceId) 0 else it.vouchDepth + 1) }148 return buildSignedDeviceManifest(ourDeviceId, devices, signing.privateKey, revoked = revoked)149 }150151 override suspend fun applyIncomingDeviceManifest(152 manifest: SignedDeviceManifest,153 authenticatedRemoteStaticPublicKey: ByteArray,154 ) {155 val registry = pairedDevices ?: return156 val pending = pendingVouched ?: return157 if (authenticatedRemoteStaticPublicKey.size != 32) return158 val issuer = registry.getByPubkey(pubkeyToHex(authenticatedRemoteStaticPublicKey)) ?: return159 160 if (issuer.trustSource != "direct" && issuer.trustSource != "vouched") return161 if (issuer.vouchDepth >= MAX_VOUCH_DEPTH) return162 val currentSigningKey = issuer.signingPublicKeyHex?.lowercase()?.takeIf { it.length == 64 } ?: return163 val knownIssuerKeys = registry.issuerSigningKeys(issuer.deviceId) + currentSigningKey164 165 166 167 168 169 170 val resolveIssuerSigningKeys: (String) -> Set<String> = { deviceId ->171 val history = registry.issuerSigningKeys(deviceId)172 val current = registry.get(deviceId)?.signingPublicKeyHex?.lowercase()173 if (current != null && current.length == 64) history + current else history174 }175 val verified = knownIssuerKeys.firstNotNullOfOrNull { candidate ->176 runCatching {177 verifyDeviceManifest(manifest, hexToPubkey(candidate), resolveIssuerSigningKeys)178 ?.let { candidate to it }179 }.getOrNull()180 } ?: return181 val (signingKey, body) = verified182 if (body.issuerDeviceId != issuer.deviceId) return183 val snapshot = requireMasterKeySnapshot()184 val master = snapshot.key185 val localKeys = localCredentials?.let(::localSyncKeypair)186 ?: throw IllegalStateException("identity_migration_required")187 try {188 if (!repo.isSessionEpochCurrent(snapshot.epoch)) return189 190 191 192 193 val incomingRevocations = body.revoked.filterNot { it.deviceId == ourDeviceId }194 val currentRevocations = registry.listRevocations()195 val knownRevokedIds = currentRevocations.map { it.deviceId }.toSet()196 val newlyRevoked = incomingRevocations.filterNot { knownRevokedIds.contains(it.deviceId) }197 val revokedIds = (knownRevokedIds + incomingRevocations.map { it.deviceId }).toSet()198 val revokedKeys = (currentRevocations + incomingRevocations)199 .map { it.longTermPublicKeyHex.lowercase() }200 .filterNot { it == NO_KEY_SENTINEL }201 .toSet()202 fun revokedInStagedBatch(deviceId: String, longTermKey: String): Boolean {203 return deviceId in revokedIds || longTermKey.lowercase() in revokedKeys204 }205206 val stagedUpserts = LinkedHashMap<String, PairedDevice>()207 val stagedAlerts = ArrayList<SyncAlert>()208 newlyRevoked.forEach { revocation ->209 stagedAlerts += SyncAlert(210 type = SyncAlertType.DEVICE_REVOKED,211 deviceName = issuer.deviceName ?: "paired device",212 entryTitle = revocation.deviceId,213 timestamp = System.currentTimeMillis(),214 )215 }216 for (entry in body.devices) {217 if (entry.deviceId == ourDeviceId || entry.deviceId == issuer.deviceId) {218 continue219 }220 if (revokedInStagedBatch(entry.deviceId, entry.longTermPublicKeyHex)) {221 continue222 }223 if (entry.vouchDepth > issuer.vouchDepth + 1) continue224 val existing = stagedUpserts[entry.deviceId] ?: registry.get(entry.deviceId)225 val meshRoute = deriveManifestMeshRoute(localKeys.secretKey, localKeys.publicKey, entry)226 if (existing == null) {227 if (meshRoute == null) continue228 stagedUpserts[entry.deviceId] = PairedDevice(229 deviceId = entry.deviceId,230 deviceName = entry.deviceName,231 deviceType = entry.deviceType,232 longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),233 signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),234 trustSource = "vouched",235 vouchDepth = issuer.vouchDepth + 1,236 wsPort = entry.wsPort,237 rendezvousSecretHex = meshRoute.first,238 relayUrl = meshRoute.second,239 pairedAt = entry.pairedAt,240 )241 } else {242 val incomingLongTermKey = entry.longTermPublicKeyHex.lowercase()243 val incomingSigningKey = entry.signingPublicKeyHex.lowercase()244 245 246 247 248 249 val longTermChanged = existing.longTermPublicKeyHex?.let {250 !CryptoCompare.hex(it, incomingLongTermKey)251 } == true252 val signingChanged = existing.signingPublicKeyHex?.let {253 !CryptoCompare.hex(it, incomingSigningKey)254 } == true255 if (longTermChanged || signingChanged) {256 deviceKeyRotationAlert(existing, entry, System.currentTimeMillis())257 ?.let(stagedAlerts::add)258 continue259 }260 deviceKeyRotationAlert(existing, entry, System.currentTimeMillis())?.let(stagedAlerts::add)261 stagedUpserts[entry.deviceId] = existing.copy(262 deviceName = existing.deviceName?.takeIf { it.isNotBlank() }263 ?: entry.deviceName,264 deviceType = entry.deviceType,265 266 267 longTermPublicKeyHex = existing.longTermPublicKeyHex ?: incomingLongTermKey,268 signingPublicKeyHex = existing.signingPublicKeyHex ?: incomingSigningKey,269 vouchDepth = entry.vouchDepth,270 wsPort = existing.wsPort ?: entry.wsPort,271 rendezvousSecretHex =272 if (existing.trustSource == "vouched" && meshRoute != null) {273 meshRoute.first274 } else {275 existing.rendezvousSecretHex276 },277 relayUrl =278 if (existing.trustSource == "vouched" && meshRoute != null) {279 meshRoute.second280 } else {281 existing.relayUrl282 },283 pairedAt = existing.pairedAt,284 trustSource = existing.trustSource,285 )286 }287 }288289 val batch = DeviceManifestRegistryBatch(290 incomingRevocations = incomingRevocations,291 removals = newlyRevoked.map { it.deviceId }.toSet(),292 upserts = stagedUpserts.values.toList(),293 )294 295 296 297 298 val pendingIds = batch.removals + stagedUpserts.keys299 val priorPending = pending.getAll().filter { it.deviceId in pendingIds }300 val committed = repo.withSessionEpochCommit(snapshot.epoch) {301 if (!registry.appendIssuerSigningKey(issuer.deviceId, signingKey)) return@withSessionEpochCommit false302 val pendingRemoved = runCatching { pending.removeBatch(pendingIds) }.getOrDefault(false)303 if (!pendingRemoved) {304 pending.restoreBatch(priorPending)305 false306 } else {307 val registryCommitted = runCatching { registry.applyManifestBatch(batch) }.getOrDefault(false)308 if (registryCommitted) {309 310 311 312 if (stagedAlerts.isNotEmpty()) syncAlerts?.record(stagedAlerts)313 true314 } else {315 pending.restoreBatch(priorPending)316 false317 }318 }319 }320 if (committed != true) return321 } finally {322 VaultCrypto.zero(master)323 VaultCrypto.zero(localKeys.secretKey)324 }325 }326327 private fun deriveManifestMeshRoute(328 localPrivateKey: ByteArray,329 localPublicKey: ByteArray,330 entry: ManifestDeviceEntry,331 ): Pair<String, String>? = runCatching {332 val relayUrl = Rendezvous.normalizeRelayUrl(entry.relayUrl ?: return null)333 val secret = Rendezvous.deriveMeshSecret(334 localPrivateKey,335 localPublicKey,336 hexToPubkey(entry.longTermPublicKeyHex),337 )338 try {339 Rendezvous.bytesToHex(secret) to relayUrl340 } finally {341 VaultCrypto.zero(secret)342 }343 }.getOrNull()344345 private fun requireMasterKeySnapshot(): VaultRepository.SyncMasterKeySnapshot =346 repo.syncMasterKeySnapshot() ?: throw VaultLockedException()347348 internal companion object {349 internal fun deriveEntryKey(masterKey: ByteArray, entryId: String): ByteArray {350 351 352 353 354 val info = (entryId + "\u0000" + ENTRY_KEY_INFO_SUFFIX).toByteArray(Charsets.UTF_8)355 val gen = HKDFBytesGenerator(SHA256Digest())356 gen.init(HKDFParameters(masterKey, ByteArray(0), info))357 return ByteArray(32).also { gen.generateBytes(it, 0, it.size) }358 }359360 internal fun encryptEntry(key: ByteArray, entry: VaultEntry): String {361 362 363 var plaintext: ByteArray? = null364 return try {365 plaintext = entryJson.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)366 val nonce = ByteArray(12).also(rng::nextBytes)367 val cipher = Cipher.getInstance("AES/GCM/NoPadding")368 cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))369 cipher.updateAAD(ENTRY_AAD)370 val ct = cipher.doFinal(plaintext)371 b64(nonce + ct)372 } finally {373 plaintext?.let(VaultCrypto::zero)374 }375 }376377 internal fun encryptEntryWithMasterKey(masterKey: ByteArray, entry: VaultEntry): String {378 val key = deriveEntryKey(masterKey, entry.id)379 return try { encryptEntry(key, entry) } finally { VaultCrypto.zero(key) }380 }381382 internal fun decryptEntryOrNull(key: ByteArray, envelopeB64: String): VaultEntry? {383 var pt: ByteArray? = null384 return try {385 val bytes = unb64(envelopeB64)386 if (bytes.size < 12 + 16) null else {387 val nonce = bytes.copyOfRange(0, 12)388 val ct = bytes.copyOfRange(12, bytes.size)389 val cipher = Cipher.getInstance("AES/GCM/NoPadding")390 cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))391 cipher.updateAAD(ENTRY_AAD)392 pt = cipher.doFinal(ct)393 entryJson.decodeFromString(VaultEntry.serializer(), String(pt, Charsets.UTF_8))394 }395 } catch (_: Exception) {396 null397 } finally {398 399 400 401 pt?.let(VaultCrypto::zero)402 }403 }404405 const val ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1"406 val ENTRY_AAD = "peach-sync-entry-v1".toByteArray(Charsets.UTF_8)407 val rng = SecureRandom()408 val entryJson = Json { encodeDefaults = false; ignoreUnknownKeys = true }409 fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b)410 fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)411 }412}413414class VaultLockedException : Exception("vault_locked")