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>) {223 receiverDeps.applySnapshot(entries, tombstones)224 }225 override suspend fun markSynced(deviceId: String) {226 receiverDeps.markSynced(deviceId)227 }228 }229 val adopted = requestAdoption(channel, boundDeps) ?: return null230 231 try {232 val (entries, tombstones) = withTimeout(15_000) {233 receiveVaultSnapshot(channel)234 }235 boundDeps.applySnapshot(entries, tombstones)236 sendFrame(channel, AdoptionFrame.SnapshotApplied(237 entriesReceived = entries.size,238 tombstonesReceived = tombstones.size,239 ))240 241 try {242 val manifestFrame = withTimeout(5_000) { receiveFrame(channel) }243 if (manifestFrame is AdoptionFrame.DeviceManifestFrame) {244 sendFrame(channel, AdoptionFrame.DeviceManifestApplied)245 }246 } catch (_: Exception) {}247 boundDeps.markSynced(adopted.deviceId)248 } catch (_: Exception) {249 250 }251 return adopted252 } finally {253 noise.close()254 }255}256257258259260261262263264265internal suspend fun runRelayAdoptionInitiatorSourceSession(266 socket: RelayBinarySocket,267 invitation: PairingInvitation,268 sourceDeps: AdoptionSourceDeps,269 confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,270): PairedDevice? {271 socket.send(272 JSONObject()273 .put("type", "PEACH_HELLO")274 .put("token", invitation.token)275 .toString()276 .toByteArray(Charsets.UTF_8),277 )278279 val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))280 require(ack.optString("type") == "PEACH_HELLO_ACK") {281 "expected PEACH_HELLO_ACK"282 }283 val responderStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))284 val noise = try {285 noiseNKInitiator(286 responderStatic,287 socket::send,288 socket::receive,289 )290 } finally {291 responderStatic.fill(0)292 }293 try {294 val channel = object : PairingTransport {295 override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))296 override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())297 override suspend fun close() = socket.close()298 }299 val request = receiveFrame(channel)300 if (!isAdoptRequest(request)) return null301 request as AdoptionFrame.Request302 if (!confirm(SASDerivation.derive(noise.handshakeHash), request)) return null303304 val pairState = AdoptionPairState(305 rendezvousSecretHex = Rendezvous.bytesToHex(306 Rendezvous.deriveSecret(noise.handshakeHash),307 ),308 relayUrl = invitation.relayUrl,309 )310 val boundDeps = object : AdoptionSourceDeps {311 override val sourceIdentity = sourceDeps.sourceIdentity312 override val pairState = pairState313 override suspend fun rootKeyHex(): String = sourceDeps.rootKeyHex()314 override suspend fun vaultId(): String = sourceDeps.vaultId()315 override suspend fun license(): String? = sourceDeps.license()316 override suspend fun applyLicense(license: String) {317 sourceDeps.applyLicense(license)318 }319 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true320 override suspend fun upsertPairedDevice(device: PairedDevice) {321 sourceDeps.upsertPairedDevice(device)322 }323 override suspend fun vaultSnapshot() = sourceDeps.vaultSnapshot()324 override suspend fun outgoingDeviceManifest() = sourceDeps.outgoingDeviceManifest()325 override suspend fun markSynced(deviceId: String) = sourceDeps.markSynced(deviceId)326 }327 val adopted = grantAdoption(channel, request, boundDeps) ?: return null328 329 if (request.snapshotCapable == true) {330 try {331 val snapshot = boundDeps.vaultSnapshot()332 if (snapshot != null) {333 sendVaultSnapshot(channel, snapshot.first, snapshot.second)334 val applied = receiveFrame(channel)335 if (applied is AdoptionFrame.SnapshotApplied) {336 val manifest = boundDeps.outgoingDeviceManifest()337 if (manifest != null) {338 sendFrame(channel, AdoptionFrame.DeviceManifestFrame(manifest))339 withTimeoutOrNull(5_000) { receiveFrame(channel) }340 }341 boundDeps.markSynced(adopted.deviceId)342 }343 }344 } catch (_: Exception) {345 346 }347 }348 return adopted349 } finally {350 noise.close()351 }352}