1package com.peachpasswords.sync23import android.util.Log4import java.security.MessageDigest5import javax.inject.Inject6import javax.inject.Singleton7import kotlinx.coroutines.CoroutineScope8import kotlinx.coroutines.Dispatchers9import kotlinx.coroutines.Job10import kotlinx.coroutines.channels.awaitClose11import kotlinx.coroutines.flow.Flow12import kotlinx.coroutines.flow.callbackFlow13import kotlinx.coroutines.launch14import kotlinx.coroutines.withTimeout15import kotlinx.coroutines.withTimeoutOrNull16import org.json.JSONObject1718sealed interface RelayAdoptionEvent {19 20 data class InvitationReady(val invitation: String) : RelayAdoptionEvent21 data class AwaitingPeerConfirmation(22 val sas: String,23 val deviceName: String,24 ) : RelayAdoptionEvent25 data class Adopted(val deviceName: String) : RelayAdoptionEvent26 data object Expired : RelayAdoptionEvent27 data class Rejected(val reason: String) : RelayAdoptionEvent28}2930@Singleton31class RelayAdoptionHost @Inject constructor(32 private val relaySettings: RelaySettingsStore,33) {34 private var job: Job? = null35 private var socket: RelayBinarySocket? = null3637 fun start(38 scope: CoroutineScope,39 ourStaticPrivateKey: ByteArray,40 ourStaticPublicKey: ByteArray,41 localIdentity: LocalDeviceIdentity,42 sourceDeps: AdoptionSourceDeps,43 windowMs: Long = PAIRING_WINDOW_MS,44 ): Flow<RelayAdoptionEvent> = callbackFlow {45 46 47 48 49 val relayUrl = when (val decision = relaySettings.relayInForce()) {50 is RelayDecision.Refuse -> {51 trySend(RelayAdoptionEvent.Rejected(decision.reason.wire()))52 close()53 return@callbackFlow54 }5556 is RelayDecision.Dial -> decision.relayUrl57 }58 val token = Rendezvous.newPairingToken()59 val invitation = createPairingInvitation(60 relayUrl,61 token,62 noiseStaticPublicKeyHex = Rendezvous.bytesToHex(ourStaticPublicKey),63 )6465 job = scope.launch(Dispatchers.IO) {66 try {67 val connected = OkHttpRelaySocket.connect(68 Rendezvous.buildUrl(relayUrl, token),69 )70 socket = connected71 72 73 trySend(RelayAdoptionEvent.InvitationReady(invitation))7475 val adopted = withTimeout(windowMs) {76 runRelayAdoptionSourceSession(77 socket = connected,78 expectedToken = token,79 relayUrl = relayUrl,80 ourStaticPrivateKey = ourStaticPrivateKey,81 ourStaticPublicKey = ourStaticPublicKey,82 localIdentity = localIdentity,83 sourceDeps = sourceDeps,84 confirm = { sas, request ->85 trySend(86 RelayAdoptionEvent.AwaitingPeerConfirmation(87 sas = sas,88 deviceName = capDeviceName(request.deviceName.ifBlank { "New device" }),89 ),90 )91 92 93 94 true95 },96 )97 }98 if (adopted == null) {99 trySend(RelayAdoptionEvent.Rejected("adoption_rejected"))100 } else {101 trySend(102 RelayAdoptionEvent.Adopted(103 capDeviceName(adopted.deviceName?.takeIf(String::isNotBlank) ?: "New device"),104 ),105 )106 }107 } catch (error: kotlinx.coroutines.TimeoutCancellationException) {108 trySend(RelayAdoptionEvent.Expired)109 } catch (error: kotlinx.coroutines.CancellationException) {110 111 112 113 114 115 throw error116 } catch (error: Throwable) {117 trySend(RelayAdoptionEvent.Rejected(error.message ?: "relay_adoption_failed"))118 } finally {119 socket?.close()120 socket = null121 close()122 }123 }124125 awaitClose { stop() }126 }127128 fun stop() {129 job?.cancel()130 job = null131 socket?.close()132 socket = null133 }134}135136137138139140141142143internal suspend fun runRelayAdoptionSourceSession(144 socket: RelayBinarySocket,145 expectedToken: String,146 relayUrl: String,147 ourStaticPrivateKey: ByteArray,148 ourStaticPublicKey: ByteArray,149 localIdentity: LocalDeviceIdentity,150 sourceDeps: AdoptionSourceDeps,151 confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,152): PairedDevice? {153 val hello = JSONObject(String(socket.receive(), Charsets.UTF_8))154 require(hello.optString("type") == "PEACH_HELLO") { "expected PEACH_HELLO" }155 require(156 MessageDigest.isEqual(157 expectedToken.toByteArray(Charsets.US_ASCII),158 hello.optString("token").toByteArray(Charsets.US_ASCII),159 ),160 ) { "pairing token mismatch" }161162 socket.send(163 JSONObject()164 .put("type", "PEACH_HELLO_ACK")165 .put(166 "ephemeralPublicKeyHex",167 ourStaticPublicKey.joinToString("") { "%02x".format(it) },168 )169 .toString()170 .toByteArray(Charsets.UTF_8),171 )172173 val noise = noiseNKResponder(174 ourStaticPrivateKey,175 ourStaticPublicKey,176 socket::send,177 socket::receive,178 )179 try {180 val channel = object : PairingTransport {181 override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))182 override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())183 override suspend fun close() = socket.close()184 }185186 val request = receiveFrame(channel)187 require(isAdoptRequest(request)) { "expected ADOPT_REQUEST" }188 request as AdoptionFrame.Request189 if (!confirm(SASDerivation.derive(noise.handshakeHash), request)) return null190191 val pairState = AdoptionPairState(192 rendezvousSecretHex = Rendezvous.bytesToHex(193 Rendezvous.deriveSecret(noise.handshakeHash),194 ),195 relayUrl = Rendezvous.normalizeRelayUrl(relayUrl),196 )197 val boundDeps = object : AdoptionSourceDeps {198 override val sourceIdentity: LocalDeviceIdentity = localIdentity199 override val pairState: AdoptionPairState = pairState200 override suspend fun rootKeyHex(): String = sourceDeps.rootKeyHex()201 override suspend fun vaultId(): String = sourceDeps.vaultId()202 override suspend fun license(): String? = sourceDeps.license()203 override suspend fun applyLicense(license: String) {204 sourceDeps.applyLicense(license)205 }206 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true207 override suspend fun upsertPairedDevice(device: PairedDevice) {208 sourceDeps.upsertPairedDevice(device)209 }210 override suspend fun vaultSnapshot() = sourceDeps.vaultSnapshot()211 override suspend fun outgoingDeviceManifest() = sourceDeps.outgoingDeviceManifest()212 override suspend fun markSynced(deviceId: String) = sourceDeps.markSynced(deviceId)213 override suspend fun duressSlotJson() = sourceDeps.duressSlotJson()214 }215 val adopted = grantAdoption(channel, request, boundDeps) ?: return null216 217 if (request.snapshotCapable == true) {218 try {219 val snapshot = boundDeps.vaultSnapshot()220 if (snapshot != null) {221 val duressSlot = runCatching { boundDeps.duressSlotJson() }.getOrNull()222 sendVaultSnapshot(channel, snapshot.first, snapshot.second, duressSlot)223 val applied = receiveFrame(channel)224 if (applied is AdoptionFrame.SnapshotApplied) {225 val manifest = boundDeps.outgoingDeviceManifest()226 if (manifest != null) {227 sendFrame(channel, AdoptionFrame.DeviceManifestFrame(manifest))228 withTimeoutOrNull(5_000) { receiveFrame(channel) }229 }230 boundDeps.markSynced(adopted.deviceId)231 }232 }233 } catch (error: Exception) {234 if (error is SnapshotDuressFrameTooLargeException) throw error235 if (error is kotlinx.coroutines.CancellationException) throw error236 Log.w("PeachAdopt", "source inline snapshot send failed, receiver falls back to sync", error)237 }238 }239 return adopted240 } finally {241 noise.close()242 }243}