RelayAdoptionReceiver.kt

14.2 KB · raw

1package com.peachpasswords.sync23import javax.inject.Inject4import javax.inject.Singleton5import kotlinx.coroutines.withTimeout6import kotlinx.coroutines.withTimeoutOrNull7import org.json.JSONObject89/**10 * Receiver half of relay-first vault adoption.11 *12 * The invitation is reachability only. The relay never sees identity, SAS, or13 * vault material; those enter only after the Noise NK session is established.14 */15@Singleton16class RelayAdoptionReceiver @Inject constructor() {17    /**18     * Phone as receiver: scan a [VaultStatus.POPULATED] invitation, dial the19     * relay room, complete Noise, and receive the source's root key. This is20     * the historical pairing direction and stays byte-for-byte unchanged.21     */22    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    /**44     * Phone as source: scan a [VaultStatus.EMPTY] invitation from an empty45     * device, dial the relay room, complete Noise, and hand our root key over.46     * Symmetric to [adopt]: the initiator runs Noise NK, but the payload47     * direction flips — the phone sends the grant, the empty device receives48     * it.49     */50    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}7172/**73 * Complete one receiver session and choose the correct socket shutdown.74 *75 * The final receiver action is an encrypted ADOPT_APPLIED frame. OkHttp's76 * send() only queues it, so a successful session must close gracefully; an77 * immediate cancel can discard that frame after both devices have already78 * persisted one another, leaving the source to report a false failure.79 */80internal 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}108109/**110 * Source-initiator counterpart to [adoptFromRelaySocket]: phone dials the111 * relay room, runs Noise as initiator, and hands the root key over to the112 * empty responder. The final source action is an encrypted ADOPT_COMPLETE,113 * so a graceful close is required to land it.114 */115internal 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}142143/**144 * Pure live-session seam used by both production and the real Noise interop145 * test. The source must already occupy the relay room before this starts.146 */147internal 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        // Capture the source's ADOPT_GRANT.snapshot_capable echo so we can skip230        // the 15s inline-snapshot wait when the source has nothing to send.231        // Default false = "pre-C source, no echo" = skip the wait.232        var sourceCanSendSnapshot = false233        val adopted = requestAdoption(234            channel,235            boundDeps,236            onGrant = { grant -> sourceCanSendSnapshot = grant.snapshotCapable },237        ) ?: return null238        if (sourceCanSendSnapshot) {239            // Try to receive the inline vault snapshot.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                // Optional device manifest from source250                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                // Fallback: sync pipeline fills the vault.259            }260        }261        return adopted262    } finally {263        noise.close()264    }265}266267/**268 * Phone-initiator source session for the "empty browser" invitation path.269 * The phone dials the relay room and waits for the empty responder to send270 * ADOPT_REQUEST, then hands over its root key through [grantAdoption]. The271 * SAS gate is the single commit signal — same model as the existing source272 * session — and onConsent is pre-cleared because the user already opted in273 * by scanning the QR.274 */275internal 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        // If the receiver advertised snapshot support, send the vault inline.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 (_: Exception) {357                // Fallback: receiver uses the sync pipeline.358            }359        }360        return adopted361    } finally {362        noise.close()363    }364}