1package com.peachpasswords.sync23import javax.inject.Inject4import javax.inject.Singleton5import kotlinx.coroutines.withTimeout6import kotlinx.coroutines.withTimeoutOrNull7import org.json.JSONObject89101112131415@Singleton16class RelayAdoptionReceiver @Inject constructor() {17 1819202122 suspend fun adopt(23 encodedInvitation: String,24 deps: AdoptionReceiverDeps,25 confirm: suspend (sas: String) -> Boolean,26 windowMs: Long = PAIRING_WINDOW_MS,27 ): PairedDevice {28 val invitation = parsePairingInvitation(encodedInvitation)29 ?: throw IllegalArgumentException("invalid_pairing_invitation")30 require(invitation.vaultStatus == VaultStatus.POPULATED) {31 "receiver adopt() expects a populated invitation; got ${invitation.vaultStatus}"32 }33 val socket = OkHttpRelaySocket.connect(invitation.roomUrl())34 return adoptFromRelaySocket(35 socket = socket,36 invitation = invitation,37 deps = deps,38 confirm = confirm,39 windowMs = windowMs,40 )41 }4243 44454647484950 suspend fun grantFromInvitation(51 encodedInvitation: String,52 sourceDeps: AdoptionSourceDeps,53 confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,54 windowMs: Long = PAIRING_WINDOW_MS,55 ): PairedDevice {56 val invitation = parsePairingInvitation(encodedInvitation)57 ?: throw IllegalArgumentException("invalid_pairing_invitation")58 require(invitation.vaultStatus == VaultStatus.EMPTY) {59 "grantFromInvitation() expects an empty invitation; got ${invitation.vaultStatus}"60 }61 val socket = OkHttpRelaySocket.connect(invitation.roomUrl())62 return grantFromRelaySocket(63 socket = socket,64 invitation = invitation,65 sourceDeps = sourceDeps,66 confirm = confirm,67 windowMs = windowMs,68 )69 }70}71727374757677787980internal suspend fun adoptFromRelaySocket(81 socket: RelayBinarySocket,82 invitation: PairingInvitation,83 deps: AdoptionReceiverDeps,84 confirm: suspend (sas: String) -> Boolean,85 windowMs: Long = PAIRING_WINDOW_MS,86): PairedDevice {87 var completed = false88 return try {89 val adopted =90 withTimeout(windowMs) {91 runRelayAdoptionReceiverSession(92 socket = socket,93 invitation = invitation,94 receiverDeps = deps,95 confirm = confirm,96 ) ?: throw IllegalStateException("adoption_rejected")97 }98 completed = true99 adopted100 } finally {101 if (completed) {102 socket.closeAfterQueuedFrames()103 } else {104 socket.close()105 }106 }107}108109110111112113114115internal suspend fun grantFromRelaySocket(116 socket: RelayBinarySocket,117 invitation: PairingInvitation,118 sourceDeps: AdoptionSourceDeps,119 confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,120 windowMs: Long = PAIRING_WINDOW_MS,121): PairedDevice {122 var completed = false123 return try {124 val granted = withTimeout(windowMs) {125 runRelayAdoptionInitiatorSourceSession(126 socket = socket,127 invitation = invitation,128 sourceDeps = sourceDeps,129 confirm = confirm,130 ) ?: throw IllegalStateException("adoption_rejected")131 }132 completed = true133 granted134 } finally {135 if (completed) {136 socket.closeAfterQueuedFrames()137 } else {138 socket.close()139 }140 }141}142143144145146147internal suspend fun runRelayAdoptionReceiverSession(148 socket: RelayBinarySocket,149 invitation: PairingInvitation,150 receiverDeps: AdoptionReceiverDeps,151 confirm: suspend (sas: String) -> Boolean,152): PairedDevice? {153 socket.send(154 JSONObject()155 .put("type", "PEACH_HELLO")156 .put("token", invitation.token)157 .toString()158 .toByteArray(Charsets.UTF_8),159 )160161 val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))162 require(ack.optString("type") == "PEACH_HELLO_ACK") {163 "expected PEACH_HELLO_ACK"164 }165 val sourceStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))166 val noise = try {167 noiseNKInitiator(168 sourceStatic,169 socket::send,170 socket::receive,171 )172 } finally {173 sourceStatic.fill(0)174 }175 try {176 val channel = object : PairingTransport {177 override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))178 override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())179 override suspend fun close() = socket.close()180 }181 val pairState = AdoptionPairState(182 rendezvousSecretHex = Rendezvous.bytesToHex(183 Rendezvous.deriveSecret(noise.handshakeHash),184 ),185 relayUrl = invitation.relayUrl,186 )187 val boundDeps = object : AdoptionReceiverDeps {188 override val deviceName = receiverDeps.deviceName189 override val deviceType = receiverDeps.deviceType190 override val pairState = pairState191 override fun generateDeviceId() = receiverDeps.generateDeviceId()192 override suspend fun confirmSas(): Boolean =193 confirm(SASDerivation.derive(noise.handshakeHash))194195 override suspend fun validateSource(source: AdoptionFrame.Source) {196 receiverDeps.validateSource(source)197 }198199 override suspend fun createVaultFromRootKey(rootKeyHex: String): String =200 receiverDeps.createVaultFromRootKey(rootKeyHex)201202 override suspend fun deriveLocalIdentity(): LocalDeviceIdentity =203 receiverDeps.deriveLocalIdentity()204205 override suspend fun clearPairedDeviceRevocation(deviceId: String) {206 receiverDeps.clearPairedDeviceRevocation(deviceId)207 }208209 override suspend fun upsertPairedDevice(device: PairedDevice) {210 receiverDeps.upsertPairedDevice(device)211 }212213 override suspend fun applyLicense(license: String) {214 receiverDeps.applyLicense(license)215 }216217 override suspend fun license(): String? = receiverDeps.license()218219 override fun onError(error: String) {220 receiverDeps.onError(error)221 }222 override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String?) {223 receiverDeps.applySnapshot(entries, tombstones, duressSlot)224 }225 override suspend fun markSynced(deviceId: String) {226 receiverDeps.markSynced(deviceId)227 }228 }229 230 231 232 var sourceCanSendSnapshot = false233 val adopted = requestAdoption(234 channel,235 boundDeps,236 onGrant = { grant -> sourceCanSendSnapshot = grant.snapshotCapable },237 ) ?: return null238 if (sourceCanSendSnapshot) {239 240 try {241 val snapshot = withTimeout(15_000) {242 receiveVaultSnapshot(channel)243 }244 boundDeps.applySnapshot(snapshot.entries, snapshot.tombstones, snapshot.duressSlot)245 sendFrame(channel, AdoptionFrame.SnapshotApplied(246 entriesReceived = snapshot.entries.size,247 tombstonesReceived = snapshot.tombstones.size,248 ))249 250 try {251 val manifestFrame = withTimeout(5_000) { receiveFrame(channel) }252 if (manifestFrame is AdoptionFrame.DeviceManifestFrame) {253 sendFrame(channel, AdoptionFrame.DeviceManifestApplied)254 }255 } catch (_: Exception) {}256 boundDeps.markSynced(adopted.deviceId)257 } catch (_: Exception) {258 259 }260 }261 return adopted262 } finally {263 noise.close()264 }265}266267268269270271272273274275internal suspend fun runRelayAdoptionInitiatorSourceSession(276 socket: RelayBinarySocket,277 invitation: PairingInvitation,278 sourceDeps: AdoptionSourceDeps,279 confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,280): PairedDevice? {281 socket.send(282 JSONObject()283 .put("type", "PEACH_HELLO")284 .put("token", invitation.token)285 .toString()286 .toByteArray(Charsets.UTF_8),287 )288289 val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))290 require(ack.optString("type") == "PEACH_HELLO_ACK") {291 "expected PEACH_HELLO_ACK"292 }293 val responderStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))294 val noise = try {295 noiseNKInitiator(296 responderStatic,297 socket::send,298 socket::receive,299 )300 } finally {301 responderStatic.fill(0)302 }303 try {304 val channel = object : PairingTransport {305 override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))306 override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())307 override suspend fun close() = socket.close()308 }309 val request = receiveFrame(channel)310 if (!isAdoptRequest(request)) return null311 request as AdoptionFrame.Request312 if (!confirm(SASDerivation.derive(noise.handshakeHash), request)) return null313314 val pairState = AdoptionPairState(315 rendezvousSecretHex = Rendezvous.bytesToHex(316 Rendezvous.deriveSecret(noise.handshakeHash),317 ),318 relayUrl = invitation.relayUrl,319 )320 val boundDeps = object : AdoptionSourceDeps {321 override val sourceIdentity = sourceDeps.sourceIdentity322 override val pairState = pairState323 override suspend fun rootKeyHex(): String = sourceDeps.rootKeyHex()324 override suspend fun vaultId(): String = sourceDeps.vaultId()325 override suspend fun license(): String? = sourceDeps.license()326 override suspend fun applyLicense(license: String) {327 sourceDeps.applyLicense(license)328 }329 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true330 override suspend fun upsertPairedDevice(device: PairedDevice) {331 sourceDeps.upsertPairedDevice(device)332 }333 override suspend fun vaultSnapshot() = sourceDeps.vaultSnapshot()334 override suspend fun outgoingDeviceManifest() = sourceDeps.outgoingDeviceManifest()335 override suspend fun markSynced(deviceId: String) = sourceDeps.markSynced(deviceId)336 override suspend fun duressSlotJson() = sourceDeps.duressSlotJson()337 }338 val adopted = grantAdoption(channel, request, boundDeps) ?: return null339 340 if (request.snapshotCapable == true) {341 try {342 val snapshot = boundDeps.vaultSnapshot()343 if (snapshot != null) {344 val duressSlot = runCatching { boundDeps.duressSlotJson() }.getOrNull()345 sendVaultSnapshot(channel, snapshot.first, snapshot.second, duressSlot)346 val applied = receiveFrame(channel)347 if (applied is AdoptionFrame.SnapshotApplied) {348 val manifest = boundDeps.outgoingDeviceManifest()349 if (manifest != null) {350 sendFrame(channel, AdoptionFrame.DeviceManifestFrame(manifest))351 withTimeoutOrNull(5_000) { receiveFrame(channel) }352 }353 boundDeps.markSynced(adopted.deviceId)354 }355 }356 } catch (error: Exception) {357 if (error is SnapshotDuressFrameTooLargeException) throw error358 359 }360 }361 return adopted362 } finally {363 noise.close()364 }365}