RelayAdoptionReceiver.kt

14.7 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.JSONObject1011/**12 * Receiver half of relay-first vault adoption.13 *14 * The invitation is reachability only. The relay never sees identity, SAS, or15 * vault material; those enter only after the Noise NK session is established.16 */17@Singleton18class RelayAdoptionReceiver @Inject constructor() {19    /**20     * Phone as receiver: scan a [VaultStatus.POPULATED] invitation, dial the21     * relay room, complete Noise, and receive the source's root key. This is22     * the historical pairing direction and stays byte-for-byte unchanged.23     */24    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    /**46     * Phone as source: scan a [VaultStatus.EMPTY] invitation from an empty47     * device, dial the relay room, complete Noise, and hand our root key over.48     * Symmetric to [adopt]: the initiator runs Noise NK, but the payload49     * direction flips — the phone sends the grant, the empty device receives50     * it.51     */52    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}7374/**75 * Complete one receiver session and choose the correct socket shutdown.76 *77 * The final receiver action is an encrypted ADOPT_APPLIED frame. OkHttp's78 * send() only queues it, so a successful session must close gracefully; an79 * immediate cancel can discard that frame after both devices have already80 * persisted one another, leaving the source to report a false failure.81 */82internal 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}110111/**112 * Source-initiator counterpart to [adoptFromRelaySocket]: phone dials the113 * relay room, runs Noise as initiator, and hands the root key over to the114 * empty responder. The final source action is an encrypted ADOPT_COMPLETE,115 * so a graceful close is required to land it.116 */117internal 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}144145/**146 * Pure live-session seam used by both production and the real Noise interop147 * test. The source must already occupy the relay room before this starts.148 */149internal 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        // Capture the source's ADOPT_GRANT.snapshot_capable echo so we can skip232        // the 15s inline-snapshot wait when the source has nothing to send.233        // Default false = "pre-C source, no echo" = skip the wait.234        var sourceCanSendSnapshot = false235        val adopted = requestAdoption(236            channel,237            boundDeps,238            onGrant = { grant -> sourceCanSendSnapshot = grant.snapshotCapable },239        ) ?: return null240        if (sourceCanSendSnapshot) {241            // Try to receive the inline vault snapshot.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                    // Optional device manifest from source253                    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}272273/**274 * Phone-initiator source session for the "empty browser" invitation path.275 * The phone dials the relay room and waits for the empty responder to send276 * ADOPT_REQUEST, then hands over its root key through [grantAdoption]. The277 * SAS gate is the single commit signal — same model as the existing source278 * session — and onConsent is pre-cleared because the user already opted in279 * by scanning the QR.280 */281internal 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        // If the receiver advertised snapshot support, send the vault inline.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}