RelayAdoptionReceiver.kt

13.5 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        val adopted = requestAdoption(channel, boundDeps) ?: return null230        // Try to receive the inline vault snapshot.231        try {232            val snapshot = withTimeout(15_000) {233                receiveVaultSnapshot(channel)234            }235            boundDeps.applySnapshot(snapshot.entries, snapshot.tombstones, snapshot.duressSlot)236            sendFrame(channel, AdoptionFrame.SnapshotApplied(237                entriesReceived = snapshot.entries.size,238                tombstonesReceived = snapshot.tombstones.size,239            ))240            // Optional device manifest from source241            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            // Fallback: sync pipeline fills the vault.250        }251        return adopted252    } finally {253        noise.close()254    }255}256257/**258 * Phone-initiator source session for the "empty browser" invitation path.259 * The phone dials the relay room and waits for the empty responder to send260 * ADOPT_REQUEST, then hands over its root key through [grantAdoption]. The261 * SAS gate is the single commit signal — same model as the existing source262 * session — and onConsent is pre-cleared because the user already opted in263 * by scanning the QR.264 */265internal 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        // If the receiver advertised snapshot support, send the vault inline.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                // Fallback: receiver uses the sync pipeline.346            }347        }348        return adopted349    } finally {350        noise.close()351    }352}