RelayAdoptionReceiver.kt

15.8 KB · raw

1package com.peachpasswords.sync23import android.util.Log4import javax.inject.Inject5import javax.inject.Singleton6import kotlinx.coroutines.CancellationException7import kotlinx.coroutines.withTimeout8import kotlinx.coroutines.withTimeoutOrNull9import org.json.JSONObject10import com.peachpasswords.sync.hexToPubkey1112/**13 * Receiver half of relay-first vault adoption.14 *15 * The invitation is reachability only. The relay never sees identity, SAS, or16 * vault material; those enter only after the Noise NK session is established.17 */18@Singleton19class RelayAdoptionReceiver @Inject constructor() {20    /**21     * Phone as receiver: scan a [VaultStatus.POPULATED] invitation, dial the22     * relay room, complete Noise, and receive the source's root key. This is23     * the historical pairing direction and stays byte-for-byte unchanged.24     */25    suspend fun adopt(26        encodedInvitation: String,27        deps: AdoptionReceiverDeps,28        confirm: suspend (sas: String) -> Boolean,29        windowMs: Long = PAIRING_WINDOW_MS,30    ): PairedDevice {31        val invitation = parsePairingInvitation(encodedInvitation)32            ?: throw IllegalArgumentException("invalid_pairing_invitation")33        require(invitation.vaultStatus == VaultStatus.POPULATED) {34            "receiver adopt() expects a populated invitation; got ${invitation.vaultStatus}"35        }36        val socket = OkHttpRelaySocket.connect(invitation.roomUrl())37        return adoptFromRelaySocket(38            socket = socket,39            invitation = invitation,40            deps = deps,41            confirm = confirm,42            windowMs = windowMs,43        )44    }4546    /**47     * Phone as source: scan a [VaultStatus.EMPTY] invitation from an empty48     * device, dial the relay room, complete Noise, and hand our root key over.49     * Symmetric to [adopt]: the initiator runs Noise NK, but the payload50     * direction flips — the phone sends the grant, the empty device receives51     * it.52     */53    suspend fun grantFromInvitation(54        encodedInvitation: String,55        sourceDeps: AdoptionSourceDeps,56        confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,57        windowMs: Long = PAIRING_WINDOW_MS,58    ): PairedDevice {59        val invitation = parsePairingInvitation(encodedInvitation)60            ?: throw IllegalArgumentException("invalid_pairing_invitation")61        require(invitation.vaultStatus == VaultStatus.EMPTY) {62            "grantFromInvitation() expects an empty invitation; got ${invitation.vaultStatus}"63        }64        val socket = OkHttpRelaySocket.connect(invitation.roomUrl())65        return grantFromRelaySocket(66            socket = socket,67            invitation = invitation,68            sourceDeps = sourceDeps,69            confirm = confirm,70            windowMs = windowMs,71        )72    }73}7475/**76 * Complete one receiver session and choose the correct socket shutdown.77 *78 * The final receiver action is an encrypted ADOPT_APPLIED frame. OkHttp's79 * send() only queues it, so a successful session must close gracefully; an80 * immediate cancel can discard that frame after both devices have already81 * persisted one another, leaving the source to report a false failure.82 */83internal suspend fun adoptFromRelaySocket(84    socket: RelayBinarySocket,85    invitation: PairingInvitation,86    deps: AdoptionReceiverDeps,87    confirm: suspend (sas: String) -> Boolean,88    windowMs: Long = PAIRING_WINDOW_MS,89): PairedDevice {90    var completed = false91    return try {92        val adopted =93            withTimeout(windowMs) {94                runRelayAdoptionReceiverSession(95                    socket = socket,96                    invitation = invitation,97                    receiverDeps = deps,98                    confirm = confirm,99                ) ?: throw IllegalStateException("adoption_rejected")100            }101        completed = true102        adopted103    } finally {104        if (completed) {105            socket.closeAfterQueuedFrames()106        } else {107            socket.close()108        }109    }110}111112/**113 * Source-initiator counterpart to [adoptFromRelaySocket]: phone dials the114 * relay room, runs Noise as initiator, and hands the root key over to the115 * empty responder. The final source action is an encrypted ADOPT_COMPLETE,116 * so a graceful close is required to land it.117 */118internal suspend fun grantFromRelaySocket(119    socket: RelayBinarySocket,120    invitation: PairingInvitation,121    sourceDeps: AdoptionSourceDeps,122    confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,123    windowMs: Long = PAIRING_WINDOW_MS,124): PairedDevice {125    var completed = false126    return try {127        val granted = withTimeout(windowMs) {128            runRelayAdoptionInitiatorSourceSession(129                socket = socket,130                invitation = invitation,131                sourceDeps = sourceDeps,132                confirm = confirm,133            ) ?: throw IllegalStateException("adoption_rejected")134        }135        completed = true136        granted137    } finally {138        if (completed) {139            socket.closeAfterQueuedFrames()140        } else {141            socket.close()142        }143    }144}145146/**147 * Pure live-session seam used by both production and the real Noise interop148 * test. The source must already occupy the relay room before this starts.149 */150internal suspend fun runRelayAdoptionReceiverSession(151    socket: RelayBinarySocket,152    invitation: PairingInvitation,153    receiverDeps: AdoptionReceiverDeps,154    confirm: suspend (sas: String) -> Boolean,155): PairedDevice? {156    socket.send(157        JSONObject()158            .put("type", "PEACH_HELLO")159            .put("token", invitation.token)160            .toString()161            .toByteArray(Charsets.UTF_8),162    )163164    val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))165    require(ack.optString("type") == "PEACH_HELLO_ACK") {166        "expected PEACH_HELLO_ACK"167    }168    val sourceStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))169    val noise = try {170        noiseNKInitiator(171            sourceStatic,172            socket::send,173            socket::receive,174        )175    } finally {176        sourceStatic.fill(0)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        }184        val pairState = AdoptionPairState(185            rendezvousSecretHex = Rendezvous.bytesToHex(186                Rendezvous.deriveSecret(noise.handshakeHash),187            ),188            relayUrl = invitation.relayUrl,189        )190        val boundDeps = object : AdoptionReceiverDeps {191            override val deviceName = receiverDeps.deviceName192            override val deviceType = receiverDeps.deviceType193            override val pairState = pairState194            override fun generateDeviceId() = receiverDeps.generateDeviceId()195            override suspend fun confirmSas(): Boolean =196                confirm(SASDerivation.derive(noise.handshakeHash))197198            override suspend fun validateSource(source: AdoptionFrame.Source) {199                receiverDeps.validateSource(source)200            }201202            override suspend fun createVaultFromRootKey(rootKeyHex: String): String =203                receiverDeps.createVaultFromRootKey(rootKeyHex)204205            override suspend fun deriveLocalIdentity(): LocalDeviceIdentity =206                receiverDeps.deriveLocalIdentity()207208            override suspend fun clearPairedDeviceRevocation(deviceId: String) {209                receiverDeps.clearPairedDeviceRevocation(deviceId)210            }211212            override suspend fun upsertPairedDevice(device: PairedDevice) {213                receiverDeps.upsertPairedDevice(device)214            }215216            override suspend fun applyLicense(license: String) {217                receiverDeps.applyLicense(license)218            }219220            override suspend fun license(): String? = receiverDeps.license()221222            override fun onError(error: String) {223                receiverDeps.onError(error)224            }225            override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String?) {226                receiverDeps.applySnapshot(entries, tombstones, duressSlot)227            }228            override suspend fun applyIncomingDeviceManifest(229                manifest: SignedDeviceManifest,230                authenticatedRemoteStaticPublicKey: ByteArray,231            ) {232                receiverDeps.applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey)233            }234            override suspend fun markSynced(deviceId: String) {235                receiverDeps.markSynced(deviceId)236            }237        }238        // Capture the source's ADOPT_GRANT.snapshot_capable echo so we can skip239        // the 15s inline-snapshot wait when the source has nothing to send.240        // Default false = "pre-C source, no echo" = skip the wait.241        var sourceCanSendSnapshot = false242        val adopted = requestAdoption(243            channel,244            boundDeps,245            onGrant = { grant -> sourceCanSendSnapshot = grant.snapshotCapable },246        ) ?: return null247        if (sourceCanSendSnapshot) {248            // Try to receive the inline vault snapshot.249            try {250                val snapshot = withTimeoutOrNull(15_000) {251                    receiveVaultSnapshot(channel)252                }253                if (snapshot != null) {254                    boundDeps.applySnapshot(snapshot.entries, snapshot.tombstones, snapshot.duressSlot)255                    sendFrame(channel, AdoptionFrame.SnapshotApplied(256                        entriesReceived = snapshot.entries.size,257                        tombstonesReceived = snapshot.tombstones.size,258                    ))259                    // Optional device manifest from source. Apply before acking so260                    // the receiver graduates into the mesh pool on the same261                    // adoption session; a missing or invalid long-term key is262                    // accepted (we still ack) because the graph can arrive via263                    // the regular sync path on the next SYNC_DEVICE_MANIFEST.264                    try {265                        val manifestFrame = withTimeoutOrNull(5_000) { receiveFrame(channel) }266                        if (manifestFrame is AdoptionFrame.DeviceManifestFrame) {267                            val sourceLongTerm = adopted.longTermPublicKeyHex268                            if (sourceLongTerm != null && sourceLongTerm.length == 64) {269                                boundDeps.applyIncomingDeviceManifest(270                                    manifestFrame.manifest,271                                    hexToPubkey(sourceLongTerm),272                                )273                            }274                            sendFrame(channel, AdoptionFrame.DeviceManifestApplied)275                        }276                    } catch (_: Exception) {}277                    boundDeps.markSynced(adopted.deviceId)278                } else {279                    Log.i("PeachAdopt", "receiver inline snapshot timed out, falling back to sync")280                }281            } catch (e: Exception) {282                Log.w("PeachAdopt", "receiver inline snapshot failed, falling back to sync", e)283            }284        }285        return adopted286    } finally {287        noise.close()288    }289}290291/**292 * Phone-initiator source session for the "empty browser" invitation path.293 * The phone dials the relay room and waits for the empty responder to send294 * ADOPT_REQUEST, then hands over its root key through [grantAdoption]. The295 * SAS gate is the single commit signal — same model as the existing source296 * session — and onConsent is pre-cleared because the user already opted in297 * by scanning the QR.298 */299internal suspend fun runRelayAdoptionInitiatorSourceSession(300    socket: RelayBinarySocket,301    invitation: PairingInvitation,302    sourceDeps: AdoptionSourceDeps,303    confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,304): PairedDevice? {305    socket.send(306        JSONObject()307            .put("type", "PEACH_HELLO")308            .put("token", invitation.token)309            .toString()310            .toByteArray(Charsets.UTF_8),311    )312313    val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))314    require(ack.optString("type") == "PEACH_HELLO_ACK") {315        "expected PEACH_HELLO_ACK"316    }317    val responderStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))318    val noise = try {319        noiseNKInitiator(320            responderStatic,321            socket::send,322            socket::receive,323        )324    } finally {325        responderStatic.fill(0)326    }327    try {328        val channel = object : PairingTransport {329            override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))330            override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())331            override suspend fun close() = socket.close()332        }333        val request = receiveFrame(channel)334        if (!isAdoptRequest(request)) return null335        request as AdoptionFrame.Request336        if (!confirm(SASDerivation.derive(noise.handshakeHash), request)) return null337338        val pairState = AdoptionPairState(339            rendezvousSecretHex = Rendezvous.bytesToHex(340                Rendezvous.deriveSecret(noise.handshakeHash),341            ),342            relayUrl = invitation.relayUrl,343        )344        val boundDeps = object : AdoptionSourceDeps {345            override val sourceIdentity = sourceDeps.sourceIdentity346            override val pairState = pairState347            override suspend fun rootKeyHex(): String = sourceDeps.rootKeyHex()348            override suspend fun vaultId(): String = sourceDeps.vaultId()349            override suspend fun license(): String? = sourceDeps.license()350            override suspend fun applyLicense(license: String) {351                sourceDeps.applyLicense(license)352            }353            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true354            override suspend fun upsertPairedDevice(device: PairedDevice) {355                sourceDeps.upsertPairedDevice(device)356            }357            override suspend fun vaultSnapshot() = sourceDeps.vaultSnapshot()358            override suspend fun outgoingDeviceManifest() = sourceDeps.outgoingDeviceManifest()359            override suspend fun markSynced(deviceId: String) = sourceDeps.markSynced(deviceId)360            override suspend fun duressSlotJson() = sourceDeps.duressSlotJson()361        }362        val adopted = grantAdoption(channel, request, boundDeps) ?: return null363        // If the receiver advertised snapshot support, send the vault inline.364        if (request.snapshotCapable == true) {365            try {366                val snapshot = boundDeps.vaultSnapshot()367                if (snapshot != null) {368                    val duressSlot = runCatching { boundDeps.duressSlotJson() }.getOrNull()369                    sendVaultSnapshot(channel, snapshot.first, snapshot.second, duressSlot)370                    val applied = receiveFrame(channel)371                    if (applied is AdoptionFrame.SnapshotApplied) {372                        val manifest = boundDeps.outgoingDeviceManifest()373                        if (manifest != null) {374                            sendFrame(channel, AdoptionFrame.DeviceManifestFrame(manifest))375                            withTimeoutOrNull(5_000) { receiveFrame(channel) }376                        }377                        boundDeps.markSynced(adopted.deviceId)378                    }379                }380            } catch (error: Exception) {381                if (error is SnapshotDuressFrameTooLargeException) throw error382                if (error is CancellationException) throw error383                Log.w("PeachAdopt", "source inline snapshot send failed, receiver falls back to sync", error)384            }385        }386        return adopted387    } finally {388        noise.close()389    }390}