1package com.peachpasswords.sync23import java.security.MessageDigest4import javax.inject.Inject5import javax.inject.Singleton6import kotlinx.coroutines.CoroutineScope7import kotlinx.coroutines.Dispatchers8import kotlinx.coroutines.Job9import kotlinx.coroutines.channels.awaitClose10import kotlinx.coroutines.flow.Flow11import kotlinx.coroutines.flow.callbackFlow12import kotlinx.coroutines.launch13import kotlinx.coroutines.withTimeout14import kotlinx.coroutines.withTimeoutOrNull15import org.json.JSONObject1617sealed interface RelayAdoptionEvent {18 19 data class InvitationReady(val invitation: String) : RelayAdoptionEvent20 data class AwaitingPeerConfirmation(21 val sas: String,22 val deviceName: String,23 ) : RelayAdoptionEvent24 data class Adopted(val deviceName: String) : RelayAdoptionEvent25 data object Expired : RelayAdoptionEvent26 data class Rejected(val reason: String) : RelayAdoptionEvent27}2829@Singleton30class RelayAdoptionHost @Inject constructor(31 private val relaySettings: RelaySettingsStore,32) {33 private var job: Job? = null34 private var socket: RelayBinarySocket? = null3536 fun start(37 scope: CoroutineScope,38 ourStaticPrivateKey: ByteArray,39 ourStaticPublicKey: ByteArray,40 localIdentity: LocalDeviceIdentity,41 sourceDeps: AdoptionSourceDeps,42 windowMs: Long = PAIRING_WINDOW_MS,43 ): Flow<RelayAdoptionEvent> = callbackFlow {44 45 46 47 48 val relayUrl = when (val decision = relaySettings.relayInForce()) {49 is RelayDecision.Refuse -> {50 trySend(RelayAdoptionEvent.Rejected(decision.reason.wire()))51 close()52 return@callbackFlow53 }5455 is RelayDecision.Dial -> decision.relayUrl56 }57 val token = Rendezvous.newPairingToken()58 val invitation = createPairingInvitation(59 relayUrl,60 token,61 noiseStaticPublicKeyHex = Rendezvous.bytesToHex(ourStaticPublicKey),62 )6364 job = scope.launch(Dispatchers.IO) {65 try {66 val connected = OkHttpRelaySocket.connectPairing(67 Rendezvous.buildUrl(relayUrl, token),68 )69 socket = connected70 71 72 trySend(RelayAdoptionEvent.InvitationReady(invitation))7374 val adopted = withTimeout(windowMs) {75 runRelayAdoptionSourceSession(76 socket = connected,77 expectedToken = token,78 relayUrl = relayUrl,79 ourStaticPrivateKey = ourStaticPrivateKey,80 ourStaticPublicKey = ourStaticPublicKey,81 localIdentity = localIdentity,82 sourceDeps = sourceDeps,83 confirm = { sas, request ->84 trySend(85 RelayAdoptionEvent.AwaitingPeerConfirmation(86 sas = sas,87 deviceName = capDeviceName(request.deviceName.ifBlank { "New device" }),88 ),89 )90 91 92 93 true94 },95 )96 }97 if (adopted == null) {98 trySend(RelayAdoptionEvent.Rejected("adoption_rejected"))99 } else {100 trySend(101 RelayAdoptionEvent.Adopted(102 capDeviceName(adopted.deviceName?.takeIf(String::isNotBlank) ?: "New device"),103 ),104 )105 }106 } catch (error: kotlinx.coroutines.TimeoutCancellationException) {107 trySend(RelayAdoptionEvent.Expired)108 } catch (error: kotlinx.coroutines.CancellationException) {109 110 111 112 113 114 throw error115 } catch (error: Throwable) {116 trySend(RelayAdoptionEvent.Rejected(error.message ?: "relay_adoption_failed"))117 } finally {118 socket?.close()119 socket = null120 close()121 }122 }123124 awaitClose { stop() }125 }126127 fun stop() {128 job?.cancel()129 job = null130 socket?.close()131 socket = null132 }133}134135136137138139140141142internal suspend fun runRelayAdoptionSourceSession(143 socket: RelayBinarySocket,144 expectedToken: String,145 relayUrl: String,146 ourStaticPrivateKey: ByteArray,147 ourStaticPublicKey: ByteArray,148 localIdentity: LocalDeviceIdentity,149 sourceDeps: AdoptionSourceDeps,150 confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,151): PairedDevice? {152 val hello = JSONObject(String(socket.receive(), Charsets.UTF_8))153 require(hello.optString("type") == "PEACH_HELLO") { "expected PEACH_HELLO" }154 require(155 MessageDigest.isEqual(156 expectedToken.toByteArray(Charsets.US_ASCII),157 hello.optString("token").toByteArray(Charsets.US_ASCII),158 ),159 ) { "pairing token mismatch" }160161 socket.send(162 JSONObject()163 .put("type", "PEACH_HELLO_ACK")164 .put(165 "ephemeralPublicKeyHex",166 ourStaticPublicKey.joinToString("") { "%02x".format(it) },167 )168 .toString()169 .toByteArray(Charsets.UTF_8),170 )171172 val noise = noiseNKResponder(173 ourStaticPrivateKey,174 ourStaticPublicKey,175 socket::send,176 socket::receive,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 }184185 val request = receiveFrame(channel)186 require(isAdoptRequest(request)) { "expected ADOPT_REQUEST" }187 request as AdoptionFrame.Request188 if (!confirm(SASDerivation.derive(noise.handshakeHash), request)) return null189190 val pairState = AdoptionPairState(191 rendezvousSecretHex = Rendezvous.bytesToHex(192 Rendezvous.deriveSecret(noise.handshakeHash),193 ),194 relayUrl = Rendezvous.normalizeRelayUrl(relayUrl),195 )196 val boundDeps = object : AdoptionSourceDeps {197 override val sourceIdentity: LocalDeviceIdentity = localIdentity198 override val pairState: AdoptionPairState = pairState199 override suspend fun rootKeyHex(): String = sourceDeps.rootKeyHex()200 override suspend fun vaultId(): String = sourceDeps.vaultId()201 override suspend fun license(): String? = sourceDeps.license()202 override suspend fun applyLicense(license: String) {203 sourceDeps.applyLicense(license)204 }205 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true206 override suspend fun upsertPairedDevice(device: PairedDevice) {207 sourceDeps.upsertPairedDevice(device)208 }209 override suspend fun vaultSnapshot() = sourceDeps.vaultSnapshot()210 override suspend fun outgoingDeviceManifest() = sourceDeps.outgoingDeviceManifest()211 override suspend fun markSynced(deviceId: String) = sourceDeps.markSynced(deviceId)212 override suspend fun duressSlotJson() = sourceDeps.duressSlotJson()213 }214 val adopted = grantAdoption(channel, request, boundDeps) ?: return null215 216 if (request.snapshotCapable == true) {217 try {218 val snapshot = boundDeps.vaultSnapshot()219 if (snapshot != null) {220 val duressSlot = runCatching { boundDeps.duressSlotJson() }.getOrNull()221 sendVaultSnapshot(channel, snapshot.first, snapshot.second, duressSlot)222 val applied = receiveFrame(channel)223 if (applied is AdoptionFrame.SnapshotApplied) {224 val manifest = boundDeps.outgoingDeviceManifest()225 if (manifest != null) {226 sendFrame(channel, AdoptionFrame.DeviceManifestFrame(manifest))227 withTimeoutOrNull(5_000) { receiveFrame(channel) }228 }229 boundDeps.markSynced(adopted.deviceId)230 }231 }232 } catch (error: Exception) {233 if (error is SnapshotDuressFrameTooLargeException) throw error234 if (error is kotlinx.coroutines.CancellationException) throw error235 PeachSyncLog.w("adoption: snapshot send failed — reason=${relayLogReason(error)}")236 }237 }238 return adopted239 } finally {240 noise.close()241 }242}