1package com.peachpasswords.sync23import java.security.MessageDigest4import javax.inject.Inject5import javax.inject.Singleton6import kotlinx.coroutines.CancellationException7import kotlinx.coroutines.withTimeout8import kotlinx.coroutines.withTimeoutOrNull9import org.json.JSONObject10import com.peachpasswords.sync.hexToPubkey1112131415161718@Singleton19class RelayAdoptionReceiver @Inject constructor(20 private val generation: SyncGenerationPort,21 private val deviceStore: PairedDeviceRegistry,22) {23 2425262728 suspend fun adopt(29 encodedInvitation: String,30 deps: AdoptionReceiverV2Deps,31 confirm: suspend (sas: String) -> Boolean,32 windowMs: Long = PAIRING_WINDOW_MS,33 ): PairedDevice {34 val invitation = parsePairingInvitation(encodedInvitation)35 ?: throw IllegalArgumentException("invalid_pairing_invitation")36 require(invitation.vaultStatus == VaultStatus.POPULATED) {37 "receiver adopt() expects a populated invitation; got ${invitation.vaultStatus}"38 }39 val socket = OkHttpRelaySocket.connectPairing(invitation.roomUrl())40 val adopted = adoptFromRelaySocket(41 socket = socket,42 invitation = invitation,43 deps = deps,44 confirm = confirm,45 windowMs = windowMs,46 )47 markRepairProgress()48 return adopted49 }5051 5253545556 private fun markRepairProgress() {57 onDirectV2PairingEstablished(58 generation,59 hasDirectDevice = deviceStore.getAll().any { it.trustSource == "direct" },60 nowMs = System.currentTimeMillis(),61 )62 }6364 65666768697071 suspend fun grantFromInvitation(72 encodedInvitation: String,73 sourceDeps: AdoptionSourceV2Deps,74 confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,75 onPeerOutdated: () -> Unit = {},76 windowMs: Long = PAIRING_WINDOW_MS,77 ): PairedDevice {78 val invitation = parsePairingInvitation(encodedInvitation)79 ?: throw IllegalArgumentException("invalid_pairing_invitation")80 require(invitation.vaultStatus == VaultStatus.EMPTY) {81 "grantFromInvitation() expects an empty invitation; got ${invitation.vaultStatus}"82 }83 val socket = OkHttpRelaySocket.connectPairing(invitation.roomUrl())84 val granted = grantFromRelaySocket(85 socket = socket,86 invitation = invitation,87 sourceDeps = sourceDeps,88 confirm = confirm,89 90 91 postReset = { generation.isPostReset(System.currentTimeMillis()) },92 onPeerOutdated = onPeerOutdated,93 windowMs = windowMs,94 )95 markRepairProgress()96 return granted97 }98}99100101102103104105106107108internal suspend fun adoptFromRelaySocket(109 socket: RelayBinarySocket,110 invitation: PairingInvitation,111 deps: AdoptionReceiverV2Deps,112 confirm: suspend (sas: String) -> Boolean,113 windowMs: Long = PAIRING_WINDOW_MS,114): PairedDevice {115 var completed = false116 return try {117 val adopted =118 withTimeout(windowMs) {119 runRelayAdoptionReceiverSession(120 socket = socket,121 invitation = invitation,122 receiverDeps = deps,123 confirm = confirm,124 ) ?: throw IllegalStateException("adoption_rejected")125 }126 completed = true127 adopted128 } finally {129 if (completed) {130 socket.closeAfterQueuedFrames()131 } else {132 socket.close()133 }134 }135}136137138139140141142143internal suspend fun grantFromRelaySocket(144 socket: RelayBinarySocket,145 invitation: PairingInvitation,146 sourceDeps: AdoptionSourceV2Deps,147 confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,148 postReset: () -> Boolean = { false },149 onPeerOutdated: () -> Unit = {},150 windowMs: Long = PAIRING_WINDOW_MS,151): PairedDevice {152 var completed = false153 return try {154 val granted = withTimeout(windowMs) {155 runRelayAdoptionInitiatorSourceSession(156 socket = socket,157 invitation = invitation,158 sourceDeps = sourceDeps,159 confirm = confirm,160 postReset = postReset,161 onPeerOutdated = onPeerOutdated,162 ) ?: throw IllegalStateException("adoption_rejected")163 }164 completed = true165 granted166 } finally {167 if (completed) {168 socket.closeAfterQueuedFrames()169 } else {170 socket.close()171 }172 }173}174175176177178179internal suspend fun runRelayAdoptionReceiverSession(180 socket: RelayBinarySocket,181 invitation: PairingInvitation,182 receiverDeps: AdoptionReceiverV2Deps,183 confirm: suspend (sas: String) -> Boolean,184): PairedDevice? {185 socket.send(186 JSONObject()187 .put("type", "PEACH_HELLO")188 .put("token", invitation.token)189 .toString()190 .toByteArray(Charsets.UTF_8),191 )192193 val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))194 require(ack.optString("type") == "PEACH_HELLO_ACK") {195 "expected PEACH_HELLO_ACK"196 }197 val sourceStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))198 val invitedStatic = invitation.noiseStaticPublicKeyHex199 ?.let(::hexToPubkey)200 ?: throw IllegalArgumentException("pairing_invitation_missing_static_key")201 val staticMatches = MessageDigest.isEqual(sourceStatic, invitedStatic)202 invitedStatic.fill(0)203 if (!staticMatches) {204 sourceStatic.fill(0)205 throw IllegalArgumentException("pairing_static_key_mismatch")206 }207 val noise = try {208 noiseNKInitiator(209 sourceStatic,210 socket::send,211 socket::receive,212 )213 } finally {214 sourceStatic.fill(0)215 }216 try {217 val channel = object : PairingTransport {218 override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))219 override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())220 override suspend fun close() = socket.close()221 }222 val pairState = AdoptionPairState(223 rendezvousSecretHex = Rendezvous.bytesToHex(224 Rendezvous.deriveSecret(noise.handshakeHash),225 ),226 relayUrl = invitation.relayUrl,227 )228 val boundDeps = object : AdoptionReceiverV2Deps {229 override val deviceName = receiverDeps.deviceName230 override val deviceType = receiverDeps.deviceType231 override val pairState = pairState232 override fun generateDeviceId() = receiverDeps.generateDeviceId()233 override suspend fun confirmSas(): Boolean =234 confirm(SASDerivation.derive(noise.handshakeHash))235236 237 238 override fun localIdentity(): LocalDeviceIdentity = receiverDeps.localIdentity()239240 override suspend fun rotateLocalIdentity(): LocalDeviceIdentity =241 receiverDeps.rotateLocalIdentity()242243 override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {244 receiverDeps.validateSource(prepare)245 }246247 override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare): Boolean =248 receiverDeps.hasSourceIdentityCollision(prepare)249250 override suspend fun validateSource(source: AdoptionFrame.Source) {251 receiverDeps.validateSource(source)252 }253254 override suspend fun hasSourceIdentityCollision(source: AdoptionFrame.Source): Boolean =255 receiverDeps.hasSourceIdentityCollision(source)256257 override suspend fun createVaultFromRootKey(rootKeyHex: String): String =258 receiverDeps.createVaultFromRootKey(rootKeyHex)259260 override suspend fun deriveLocalIdentity(): LocalDeviceIdentity =261 receiverDeps.deriveLocalIdentity()262 override suspend fun clearPairedDeviceRevocation(deviceId: String) {263 receiverDeps.clearPairedDeviceRevocation(deviceId)264 }265266 override suspend fun upsertPairedDeviceAndClearRevocation(device: PairedDevice) {267 receiverDeps.upsertPairedDeviceAndClearRevocation(device)268 }269270 override suspend fun upsertPairedDevice(device: PairedDevice) {271 receiverDeps.upsertPairedDevice(device)272 }273274 override suspend fun applyLicense(license: String) {275 receiverDeps.applyLicense(license)276 }277278 override suspend fun license(): String? = receiverDeps.license()279280 override fun onError(error: String) {281 receiverDeps.onError(error)282 }283 override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String?) {284 receiverDeps.applySnapshot(entries, tombstones, duressSlot)285 }286 override suspend fun applyIncomingDeviceManifest(287 manifest: SignedDeviceManifest,288 authenticatedRemoteStaticPublicKey: ByteArray,289 ) {290 receiverDeps.applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey)291 }292 override suspend fun markSynced(deviceId: String) {293 receiverDeps.markSynced(deviceId)294 }295 }296 297 298 var sourceCanSendSnapshot = false299 val adopted = requestAdoptionV2(300 channel,301 boundDeps,302 onPrepare = { prepare -> sourceCanSendSnapshot = prepare.snapshotCapable == true },303 ) ?: return null304 if (sourceCanSendSnapshot) {305 306 try {307 val snapshot = withTimeoutOrNull(15_000) {308 receiveVaultSnapshot(channel)309 }310 if (snapshot != null) {311 boundDeps.applySnapshot(snapshot.entries, snapshot.tombstones, snapshot.duressSlot)312 sendFrame(channel, AdoptionFrame.SnapshotApplied(313 entriesReceived = snapshot.entries.size,314 tombstonesReceived = snapshot.tombstones.size,315 ))316 317 318 319 320 321 try {322 val manifestFrame = withTimeoutOrNull(5_000) { receiveFrame(channel) }323 if (manifestFrame is AdoptionFrame.DeviceManifestFrame) {324 val sourceLongTerm = adopted.longTermPublicKeyHex325 if (sourceLongTerm != null && sourceLongTerm.length == 64) {326 boundDeps.applyIncomingDeviceManifest(327 manifestFrame.manifest,328 hexToPubkey(sourceLongTerm),329 )330 }331 sendFrame(channel, AdoptionFrame.DeviceManifestApplied)332 }333 } catch (_: Exception) {}334 boundDeps.markSynced(adopted.deviceId)335 } else {336 PeachSyncLog.i("adoption: snapshot receive timed out")337 }338 } catch (e: Exception) {339 PeachSyncLog.w("adoption: snapshot receive failed — reason=${relayLogReason(e)}")340 }341 }342 return adopted343 } finally {344 noise.close()345 }346}347348349350351352353354355356internal suspend fun runRelayAdoptionInitiatorSourceSession(357 socket: RelayBinarySocket,358 invitation: PairingInvitation,359 sourceDeps: AdoptionSourceV2Deps,360 confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,361 postReset: () -> Boolean = { false },362 onPeerOutdated: () -> Unit = {},363): PairedDevice? {364 socket.send(365 JSONObject()366 .put("type", "PEACH_HELLO")367 .put("token", invitation.token)368 .toString()369 .toByteArray(Charsets.UTF_8),370 )371372 val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))373 require(ack.optString("type") == "PEACH_HELLO_ACK") {374 "expected PEACH_HELLO_ACK"375 }376 377 378 379 380 381 382 val responderStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))383 val invitedStatic = invitation.noiseStaticPublicKeyHex384 ?.let(::hexToPubkey)385 ?: throw IllegalArgumentException("pairing_invitation_missing_static_key")386 val staticMatches = MessageDigest.isEqual(responderStatic, invitedStatic)387 invitedStatic.fill(0)388 if (!staticMatches) {389 responderStatic.fill(0)390 throw IllegalArgumentException("pairing_static_key_mismatch")391 }392 val noise = try {393 noiseNKInitiator(394 responderStatic,395 socket::send,396 socket::receive,397 )398 } finally {399 responderStatic.fill(0)400 }401 try {402 val channel = object : PairingTransport {403 override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))404 override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())405 override suspend fun close() = socket.close()406 }407 val request = receiveFrame(channel)408 if (request !is AdoptionFrame.RequestV2 && !isAdoptRequest(request)) return null409 410 411 412 413 414 if (request is AdoptionFrame.Request && !allowLegacyV1(postReset())) {415 onPeerOutdated()416 return null417 }418419 val pairState = AdoptionPairState(420 rendezvousSecretHex = Rendezvous.bytesToHex(421 Rendezvous.deriveSecret(noise.handshakeHash),422 ),423 relayUrl = invitation.relayUrl,424 )425 val boundDeps = bindSourceSessionDeps(426 sourceDeps = sourceDeps,427 sourceIdentity = sourceDeps.sourceIdentity,428 pairState = pairState,429 sas = SASDerivation.derive(noise.handshakeHash),430 snapshotCapable = true,431 confirm = confirm,432 )433 434 435 val adopted = when (request) {436 is AdoptionFrame.RequestV2 -> grantAdoptionV2(channel, request, boundDeps)437 is AdoptionFrame.Request -> {438 if (!confirm(SASDerivation.derive(noise.handshakeHash), request)) return null439 grantAdoption(channel, request, boundDeps)440 }441 else -> null442 } ?: return null443 444 val receiverWantsSnapshot = when (request) {445 is AdoptionFrame.Request -> request.snapshotCapable == true446 is AdoptionFrame.RequestV2 -> request.snapshotCapable == true447 else -> false448 }449 if (receiverWantsSnapshot) {450 try {451 val snapshot = boundDeps.vaultSnapshot()452 if (snapshot != null) {453 val duressSlot = runCatching { boundDeps.duressSlotJson() }.getOrNull()454 sendVaultSnapshot(channel, snapshot.first, snapshot.second, duressSlot)455 val applied = receiveFrame(channel)456 if (applied is AdoptionFrame.SnapshotApplied) {457 val manifest = boundDeps.outgoingDeviceManifest()458 if (manifest != null) {459 sendFrame(channel, AdoptionFrame.DeviceManifestFrame(manifest))460 withTimeoutOrNull(5_000) { receiveFrame(channel) }461 }462 boundDeps.markSynced(adopted.deviceId)463 }464 }465 } catch (error: Exception) {466 if (error is SnapshotDuressFrameTooLargeException) throw error467 if (error is CancellationException) throw error468 PeachSyncLog.w("adoption: snapshot send failed — reason=${relayLogReason(error)}")469 }470 }471 return adopted472 } finally {473 noise.close()474 }475}