1package com.peachpasswords.sync23import android.util.Log4import javax.inject.Inject5import javax.inject.Singleton6import kotlinx.coroutines.CancellationException7import kotlinx.coroutines.withTimeout8import kotlinx.coroutines.withTimeoutOrNull9import org.json.JSONObject1011121314151617@Singleton18class RelayAdoptionReceiver @Inject constructor() {19 2021222324 suspend fun adopt(25 encodedInvitation: String,26 deps: AdoptionReceiverDeps,27 confirm: suspend (sas: String) -> Boolean,28 windowMs: Long = PAIRING_WINDOW_MS,29 ): PairedDevice {30 val invitation = parsePairingInvitation(encodedInvitation)31 ?: throw IllegalArgumentException("invalid_pairing_invitation")32 require(invitation.vaultStatus == VaultStatus.POPULATED) {33 "receiver adopt() expects a populated invitation; got ${invitation.vaultStatus}"34 }35 val socket = OkHttpRelaySocket.connect(invitation.roomUrl())36 return adoptFromRelaySocket(37 socket = socket,38 invitation = invitation,39 deps = deps,40 confirm = confirm,41 windowMs = windowMs,42 )43 }4445 46474849505152 suspend fun grantFromInvitation(53 encodedInvitation: String,54 sourceDeps: AdoptionSourceDeps,55 confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,56 windowMs: Long = PAIRING_WINDOW_MS,57 ): PairedDevice {58 val invitation = parsePairingInvitation(encodedInvitation)59 ?: throw IllegalArgumentException("invalid_pairing_invitation")60 require(invitation.vaultStatus == VaultStatus.EMPTY) {61 "grantFromInvitation() expects an empty invitation; got ${invitation.vaultStatus}"62 }63 val socket = OkHttpRelaySocket.connect(invitation.roomUrl())64 return grantFromRelaySocket(65 socket = socket,66 invitation = invitation,67 sourceDeps = sourceDeps,68 confirm = confirm,69 windowMs = windowMs,70 )71 }72}73747576777879808182internal suspend fun adoptFromRelaySocket(83 socket: RelayBinarySocket,84 invitation: PairingInvitation,85 deps: AdoptionReceiverDeps,86 confirm: suspend (sas: String) -> Boolean,87 windowMs: Long = PAIRING_WINDOW_MS,88): PairedDevice {89 var completed = false90 return try {91 val adopted =92 withTimeout(windowMs) {93 runRelayAdoptionReceiverSession(94 socket = socket,95 invitation = invitation,96 receiverDeps = deps,97 confirm = confirm,98 ) ?: throw IllegalStateException("adoption_rejected")99 }100 completed = true101 adopted102 } finally {103 if (completed) {104 socket.closeAfterQueuedFrames()105 } else {106 socket.close()107 }108 }109}110111112113114115116117internal suspend fun grantFromRelaySocket(118 socket: RelayBinarySocket,119 invitation: PairingInvitation,120 sourceDeps: AdoptionSourceDeps,121 confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,122 windowMs: Long = PAIRING_WINDOW_MS,123): PairedDevice {124 var completed = false125 return try {126 val granted = withTimeout(windowMs) {127 runRelayAdoptionInitiatorSourceSession(128 socket = socket,129 invitation = invitation,130 sourceDeps = sourceDeps,131 confirm = confirm,132 ) ?: throw IllegalStateException("adoption_rejected")133 }134 completed = true135 granted136 } finally {137 if (completed) {138 socket.closeAfterQueuedFrames()139 } else {140 socket.close()141 }142 }143}144145146147148149internal suspend fun runRelayAdoptionReceiverSession(150 socket: RelayBinarySocket,151 invitation: PairingInvitation,152 receiverDeps: AdoptionReceiverDeps,153 confirm: suspend (sas: String) -> Boolean,154): PairedDevice? {155 socket.send(156 JSONObject()157 .put("type", "PEACH_HELLO")158 .put("token", invitation.token)159 .toString()160 .toByteArray(Charsets.UTF_8),161 )162163 val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))164 require(ack.optString("type") == "PEACH_HELLO_ACK") {165 "expected PEACH_HELLO_ACK"166 }167 val sourceStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))168 val noise = try {169 noiseNKInitiator(170 sourceStatic,171 socket::send,172 socket::receive,173 )174 } finally {175 sourceStatic.fill(0)176 }177 try {178 val channel = object : PairingTransport {179 override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))180 override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())181 override suspend fun close() = socket.close()182 }183 val pairState = AdoptionPairState(184 rendezvousSecretHex = Rendezvous.bytesToHex(185 Rendezvous.deriveSecret(noise.handshakeHash),186 ),187 relayUrl = invitation.relayUrl,188 )189 val boundDeps = object : AdoptionReceiverDeps {190 override val deviceName = receiverDeps.deviceName191 override val deviceType = receiverDeps.deviceType192 override val pairState = pairState193 override fun generateDeviceId() = receiverDeps.generateDeviceId()194 override suspend fun confirmSas(): Boolean =195 confirm(SASDerivation.derive(noise.handshakeHash))196197 override suspend fun validateSource(source: AdoptionFrame.Source) {198 receiverDeps.validateSource(source)199 }200201 override suspend fun createVaultFromRootKey(rootKeyHex: String): String =202 receiverDeps.createVaultFromRootKey(rootKeyHex)203204 override suspend fun deriveLocalIdentity(): LocalDeviceIdentity =205 receiverDeps.deriveLocalIdentity()206207 override suspend fun clearPairedDeviceRevocation(deviceId: String) {208 receiverDeps.clearPairedDeviceRevocation(deviceId)209 }210211 override suspend fun upsertPairedDevice(device: PairedDevice) {212 receiverDeps.upsertPairedDevice(device)213 }214215 override suspend fun applyLicense(license: String) {216 receiverDeps.applyLicense(license)217 }218219 override suspend fun license(): String? = receiverDeps.license()220221 override fun onError(error: String) {222 receiverDeps.onError(error)223 }224 override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String?) {225 receiverDeps.applySnapshot(entries, tombstones, duressSlot)226 }227 override suspend fun markSynced(deviceId: String) {228 receiverDeps.markSynced(deviceId)229 }230 }231 232 233 234 var sourceCanSendSnapshot = false235 val adopted = requestAdoption(236 channel,237 boundDeps,238 onGrant = { grant -> sourceCanSendSnapshot = grant.snapshotCapable },239 ) ?: return null240 if (sourceCanSendSnapshot) {241 242 try {243 val snapshot = withTimeoutOrNull(15_000) {244 receiveVaultSnapshot(channel)245 }246 if (snapshot != null) {247 boundDeps.applySnapshot(snapshot.entries, snapshot.tombstones, snapshot.duressSlot)248 sendFrame(channel, AdoptionFrame.SnapshotApplied(249 entriesReceived = snapshot.entries.size,250 tombstonesReceived = snapshot.tombstones.size,251 ))252 253 try {254 val manifestFrame = withTimeoutOrNull(5_000) { receiveFrame(channel) }255 if (manifestFrame is AdoptionFrame.DeviceManifestFrame) {256 sendFrame(channel, AdoptionFrame.DeviceManifestApplied)257 }258 } catch (_: Exception) {}259 boundDeps.markSynced(adopted.deviceId)260 } else {261 Log.i("PeachAdopt", "receiver inline snapshot timed out, falling back to sync")262 }263 } catch (e: Exception) {264 Log.w("PeachAdopt", "receiver inline snapshot failed, falling back to sync", e)265 }266 }267 return adopted268 } finally {269 noise.close()270 }271}272273274275276277278279280281internal suspend fun runRelayAdoptionInitiatorSourceSession(282 socket: RelayBinarySocket,283 invitation: PairingInvitation,284 sourceDeps: AdoptionSourceDeps,285 confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,286): PairedDevice? {287 socket.send(288 JSONObject()289 .put("type", "PEACH_HELLO")290 .put("token", invitation.token)291 .toString()292 .toByteArray(Charsets.UTF_8),293 )294295 val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))296 require(ack.optString("type") == "PEACH_HELLO_ACK") {297 "expected PEACH_HELLO_ACK"298 }299 val responderStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))300 val noise = try {301 noiseNKInitiator(302 responderStatic,303 socket::send,304 socket::receive,305 )306 } finally {307 responderStatic.fill(0)308 }309 try {310 val channel = object : PairingTransport {311 override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))312 override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())313 override suspend fun close() = socket.close()314 }315 val request = receiveFrame(channel)316 if (!isAdoptRequest(request)) return null317 request as AdoptionFrame.Request318 if (!confirm(SASDerivation.derive(noise.handshakeHash), request)) return null319320 val pairState = AdoptionPairState(321 rendezvousSecretHex = Rendezvous.bytesToHex(322 Rendezvous.deriveSecret(noise.handshakeHash),323 ),324 relayUrl = invitation.relayUrl,325 )326 val boundDeps = object : AdoptionSourceDeps {327 override val sourceIdentity = sourceDeps.sourceIdentity328 override val pairState = pairState329 override suspend fun rootKeyHex(): String = sourceDeps.rootKeyHex()330 override suspend fun vaultId(): String = sourceDeps.vaultId()331 override suspend fun license(): String? = sourceDeps.license()332 override suspend fun applyLicense(license: String) {333 sourceDeps.applyLicense(license)334 }335 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true336 override suspend fun upsertPairedDevice(device: PairedDevice) {337 sourceDeps.upsertPairedDevice(device)338 }339 override suspend fun vaultSnapshot() = sourceDeps.vaultSnapshot()340 override suspend fun outgoingDeviceManifest() = sourceDeps.outgoingDeviceManifest()341 override suspend fun markSynced(deviceId: String) = sourceDeps.markSynced(deviceId)342 override suspend fun duressSlotJson() = sourceDeps.duressSlotJson()343 }344 val adopted = grantAdoption(channel, request, boundDeps) ?: return null345 346 if (request.snapshotCapable == true) {347 try {348 val snapshot = boundDeps.vaultSnapshot()349 if (snapshot != null) {350 val duressSlot = runCatching { boundDeps.duressSlotJson() }.getOrNull()351 sendVaultSnapshot(channel, snapshot.first, snapshot.second, duressSlot)352 val applied = receiveFrame(channel)353 if (applied is AdoptionFrame.SnapshotApplied) {354 val manifest = boundDeps.outgoingDeviceManifest()355 if (manifest != null) {356 sendFrame(channel, AdoptionFrame.DeviceManifestFrame(manifest))357 withTimeoutOrNull(5_000) { receiveFrame(channel) }358 }359 boundDeps.markSynced(adopted.deviceId)360 }361 }362 } catch (error: Exception) {363 if (error is SnapshotDuressFrameTooLargeException) throw error364 if (error is CancellationException) throw error365 Log.w("PeachAdopt", "source inline snapshot send failed, receiver falls back to sync", error)366 }367 }368 return adopted369 } finally {370 noise.close()371 }372}