1package com.peachpasswords.sync23import android.util.Log4import 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 2122232425 suspend fun adopt(26 encodedInvitation: String,27 deps: AdoptionReceiverDeps,28 confirm: suspend (sas: String) -> Boolean,29 windowMs: Long = PAIRING_WINDOW_MS,30 ): PairedDevice {31 val invitation = parsePairingInvitation(encodedInvitation)32 ?: throw IllegalArgumentException("invalid_pairing_invitation")33 require(invitation.vaultStatus == VaultStatus.POPULATED) {34 "receiver adopt() expects a populated invitation; got ${invitation.vaultStatus}"35 }36 val socket = OkHttpRelaySocket.connect(invitation.roomUrl())37 return adoptFromRelaySocket(38 socket = socket,39 invitation = invitation,40 deps = deps,41 confirm = confirm,42 windowMs = windowMs,43 )44 }4546 47484950515253 suspend fun grantFromInvitation(54 encodedInvitation: String,55 sourceDeps: AdoptionSourceDeps,56 confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,57 windowMs: Long = PAIRING_WINDOW_MS,58 ): PairedDevice {59 val invitation = parsePairingInvitation(encodedInvitation)60 ?: throw IllegalArgumentException("invalid_pairing_invitation")61 require(invitation.vaultStatus == VaultStatus.EMPTY) {62 "grantFromInvitation() expects an empty invitation; got ${invitation.vaultStatus}"63 }64 val socket = OkHttpRelaySocket.connect(invitation.roomUrl())65 return grantFromRelaySocket(66 socket = socket,67 invitation = invitation,68 sourceDeps = sourceDeps,69 confirm = confirm,70 windowMs = windowMs,71 )72 }73}74757677787980818283internal suspend fun adoptFromRelaySocket(84 socket: RelayBinarySocket,85 invitation: PairingInvitation,86 deps: AdoptionReceiverDeps,87 confirm: suspend (sas: String) -> Boolean,88 windowMs: Long = PAIRING_WINDOW_MS,89): PairedDevice {90 var completed = false91 return try {92 val adopted =93 withTimeout(windowMs) {94 runRelayAdoptionReceiverSession(95 socket = socket,96 invitation = invitation,97 receiverDeps = deps,98 confirm = confirm,99 ) ?: throw IllegalStateException("adoption_rejected")100 }101 completed = true102 adopted103 } finally {104 if (completed) {105 socket.closeAfterQueuedFrames()106 } else {107 socket.close()108 }109 }110}111112113114115116117118internal suspend fun grantFromRelaySocket(119 socket: RelayBinarySocket,120 invitation: PairingInvitation,121 sourceDeps: AdoptionSourceDeps,122 confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,123 windowMs: Long = PAIRING_WINDOW_MS,124): PairedDevice {125 var completed = false126 return try {127 val granted = withTimeout(windowMs) {128 runRelayAdoptionInitiatorSourceSession(129 socket = socket,130 invitation = invitation,131 sourceDeps = sourceDeps,132 confirm = confirm,133 ) ?: throw IllegalStateException("adoption_rejected")134 }135 completed = true136 granted137 } finally {138 if (completed) {139 socket.closeAfterQueuedFrames()140 } else {141 socket.close()142 }143 }144}145146147148149150internal suspend fun runRelayAdoptionReceiverSession(151 socket: RelayBinarySocket,152 invitation: PairingInvitation,153 receiverDeps: AdoptionReceiverDeps,154 confirm: suspend (sas: String) -> Boolean,155): PairedDevice? {156 socket.send(157 JSONObject()158 .put("type", "PEACH_HELLO")159 .put("token", invitation.token)160 .toString()161 .toByteArray(Charsets.UTF_8),162 )163164 val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))165 require(ack.optString("type") == "PEACH_HELLO_ACK") {166 "expected PEACH_HELLO_ACK"167 }168 val sourceStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))169 val noise = try {170 noiseNKInitiator(171 sourceStatic,172 socket::send,173 socket::receive,174 )175 } finally {176 sourceStatic.fill(0)177 }178 try {179 val channel = object : PairingTransport {180 override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))181 override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())182 override suspend fun close() = socket.close()183 }184 val pairState = AdoptionPairState(185 rendezvousSecretHex = Rendezvous.bytesToHex(186 Rendezvous.deriveSecret(noise.handshakeHash),187 ),188 relayUrl = invitation.relayUrl,189 )190 val boundDeps = object : AdoptionReceiverDeps {191 override val deviceName = receiverDeps.deviceName192 override val deviceType = receiverDeps.deviceType193 override val pairState = pairState194 override fun generateDeviceId() = receiverDeps.generateDeviceId()195 override suspend fun confirmSas(): Boolean =196 confirm(SASDerivation.derive(noise.handshakeHash))197198 override suspend fun validateSource(source: AdoptionFrame.Source) {199 receiverDeps.validateSource(source)200 }201202 override suspend fun createVaultFromRootKey(rootKeyHex: String): String =203 receiverDeps.createVaultFromRootKey(rootKeyHex)204205 override suspend fun deriveLocalIdentity(): LocalDeviceIdentity =206 receiverDeps.deriveLocalIdentity()207208 override suspend fun clearPairedDeviceRevocation(deviceId: String) {209 receiverDeps.clearPairedDeviceRevocation(deviceId)210 }211212 override suspend fun upsertPairedDevice(device: PairedDevice) {213 receiverDeps.upsertPairedDevice(device)214 }215216 override suspend fun applyLicense(license: String) {217 receiverDeps.applyLicense(license)218 }219220 override suspend fun license(): String? = receiverDeps.license()221222 override fun onError(error: String) {223 receiverDeps.onError(error)224 }225 override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String?) {226 receiverDeps.applySnapshot(entries, tombstones, duressSlot)227 }228 override suspend fun applyIncomingDeviceManifest(229 manifest: SignedDeviceManifest,230 authenticatedRemoteStaticPublicKey: ByteArray,231 ) {232 receiverDeps.applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey)233 }234 override suspend fun markSynced(deviceId: String) {235 receiverDeps.markSynced(deviceId)236 }237 }238 239 240 241 var sourceCanSendSnapshot = false242 val adopted = requestAdoption(243 channel,244 boundDeps,245 onGrant = { grant -> sourceCanSendSnapshot = grant.snapshotCapable },246 ) ?: return null247 if (sourceCanSendSnapshot) {248 249 try {250 val snapshot = withTimeoutOrNull(15_000) {251 receiveVaultSnapshot(channel)252 }253 if (snapshot != null) {254 boundDeps.applySnapshot(snapshot.entries, snapshot.tombstones, snapshot.duressSlot)255 sendFrame(channel, AdoptionFrame.SnapshotApplied(256 entriesReceived = snapshot.entries.size,257 tombstonesReceived = snapshot.tombstones.size,258 ))259 260 261 262 263 264 try {265 val manifestFrame = withTimeoutOrNull(5_000) { receiveFrame(channel) }266 if (manifestFrame is AdoptionFrame.DeviceManifestFrame) {267 val sourceLongTerm = adopted.longTermPublicKeyHex268 if (sourceLongTerm != null && sourceLongTerm.length == 64) {269 boundDeps.applyIncomingDeviceManifest(270 manifestFrame.manifest,271 hexToPubkey(sourceLongTerm),272 )273 }274 sendFrame(channel, AdoptionFrame.DeviceManifestApplied)275 }276 } catch (_: Exception) {}277 boundDeps.markSynced(adopted.deviceId)278 } else {279 Log.i("PeachAdopt", "receiver inline snapshot timed out, falling back to sync")280 }281 } catch (e: Exception) {282 Log.w("PeachAdopt", "receiver inline snapshot failed, falling back to sync", e)283 }284 }285 return adopted286 } finally {287 noise.close()288 }289}290291292293294295296297298299internal suspend fun runRelayAdoptionInitiatorSourceSession(300 socket: RelayBinarySocket,301 invitation: PairingInvitation,302 sourceDeps: AdoptionSourceDeps,303 confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,304): PairedDevice? {305 socket.send(306 JSONObject()307 .put("type", "PEACH_HELLO")308 .put("token", invitation.token)309 .toString()310 .toByteArray(Charsets.UTF_8),311 )312313 val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))314 require(ack.optString("type") == "PEACH_HELLO_ACK") {315 "expected PEACH_HELLO_ACK"316 }317 val responderStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))318 val noise = try {319 noiseNKInitiator(320 responderStatic,321 socket::send,322 socket::receive,323 )324 } finally {325 responderStatic.fill(0)326 }327 try {328 val channel = object : PairingTransport {329 override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))330 override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())331 override suspend fun close() = socket.close()332 }333 val request = receiveFrame(channel)334 if (!isAdoptRequest(request)) return null335 request as AdoptionFrame.Request336 if (!confirm(SASDerivation.derive(noise.handshakeHash), request)) return null337338 val pairState = AdoptionPairState(339 rendezvousSecretHex = Rendezvous.bytesToHex(340 Rendezvous.deriveSecret(noise.handshakeHash),341 ),342 relayUrl = invitation.relayUrl,343 )344 val boundDeps = object : AdoptionSourceDeps {345 override val sourceIdentity = sourceDeps.sourceIdentity346 override val pairState = pairState347 override suspend fun rootKeyHex(): String = sourceDeps.rootKeyHex()348 override suspend fun vaultId(): String = sourceDeps.vaultId()349 override suspend fun license(): String? = sourceDeps.license()350 override suspend fun applyLicense(license: String) {351 sourceDeps.applyLicense(license)352 }353 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true354 override suspend fun upsertPairedDevice(device: PairedDevice) {355 sourceDeps.upsertPairedDevice(device)356 }357 override suspend fun vaultSnapshot() = sourceDeps.vaultSnapshot()358 override suspend fun outgoingDeviceManifest() = sourceDeps.outgoingDeviceManifest()359 override suspend fun markSynced(deviceId: String) = sourceDeps.markSynced(deviceId)360 override suspend fun duressSlotJson() = sourceDeps.duressSlotJson()361 }362 val adopted = grantAdoption(channel, request, boundDeps) ?: return null363 364 if (request.snapshotCapable == true) {365 try {366 val snapshot = boundDeps.vaultSnapshot()367 if (snapshot != null) {368 val duressSlot = runCatching { boundDeps.duressSlotJson() }.getOrNull()369 sendVaultSnapshot(channel, snapshot.first, snapshot.second, duressSlot)370 val applied = receiveFrame(channel)371 if (applied is AdoptionFrame.SnapshotApplied) {372 val manifest = boundDeps.outgoingDeviceManifest()373 if (manifest != null) {374 sendFrame(channel, AdoptionFrame.DeviceManifestFrame(manifest))375 withTimeoutOrNull(5_000) { receiveFrame(channel) }376 }377 boundDeps.markSynced(adopted.deviceId)378 }379 }380 } catch (error: Exception) {381 if (error is SnapshotDuressFrameTooLargeException) throw error382 if (error is CancellationException) throw error383 Log.w("PeachAdopt", "source inline snapshot send failed, receiver falls back to sync", error)384 }385 }386 return adopted387 } finally {388 noise.close()389 }390}