RelayAdoptionReceiver.kt

5.2 KB · raw

1package com.peachpasswords.sync23import javax.inject.Inject4import javax.inject.Singleton5import kotlinx.coroutines.withTimeout6import org.json.JSONObject78/**9 * Receiver half of relay-first vault adoption.10 *11 * The invitation is reachability only. The relay never sees identity, SAS, or12 * vault material; those enter only after the Noise NK session is established.13 */14@Singleton15class RelayAdoptionReceiver @Inject constructor() {16    suspend fun adopt(17        encodedInvitation: String,18        deps: AdoptionReceiverDeps,19        confirm: suspend (sas: String) -> Boolean,20        windowMs: Long = PAIRING_WINDOW_MS,21    ): PairedDevice {22        val invitation = parsePairingInvitation(encodedInvitation)23            ?: throw IllegalArgumentException("invalid_pairing_invitation")24        val socket = OkHttpRelaySocket.connect(invitation.roomUrl())25        return adoptFromRelaySocket(26            socket = socket,27            invitation = invitation,28            deps = deps,29            confirm = confirm,30            windowMs = windowMs,31        )32    }33}3435/**36 * Complete one receiver session and choose the correct socket shutdown.37 *38 * The final receiver action is an encrypted ADOPT_APPLIED frame. OkHttp's39 * send() only queues it, so a successful session must close gracefully; an40 * immediate cancel can discard that frame after both devices have already41 * persisted one another, leaving the source to report a false failure.42 */43internal suspend fun adoptFromRelaySocket(44    socket: RelayBinarySocket,45    invitation: PairingInvitation,46    deps: AdoptionReceiverDeps,47    confirm: suspend (sas: String) -> Boolean,48    windowMs: Long = PAIRING_WINDOW_MS,49): PairedDevice {50    var completed = false51    return try {52        val adopted =53            withTimeout(windowMs) {54                runRelayAdoptionReceiverSession(55                    socket = socket,56                    invitation = invitation,57                    receiverDeps = deps,58                    confirm = confirm,59                ) ?: throw IllegalStateException("adoption_rejected")60            }61        completed = true62        adopted63    } finally {64        if (completed) {65            socket.closeAfterQueuedFrames()66        } else {67            socket.close()68        }69    }70}7172/**73 * Pure live-session seam used by both production and the real Noise interop74 * test. The source must already occupy the relay room before this starts.75 */76internal suspend fun runRelayAdoptionReceiverSession(77    socket: RelayBinarySocket,78    invitation: PairingInvitation,79    receiverDeps: AdoptionReceiverDeps,80    confirm: suspend (sas: String) -> Boolean,81): PairedDevice? {82    socket.send(83        JSONObject()84            .put("type", "PEACH_HELLO")85            .put("token", invitation.token)86            .toString()87            .toByteArray(Charsets.UTF_8),88    )8990    val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))91    require(ack.optString("type") == "PEACH_HELLO_ACK") {92        "expected PEACH_HELLO_ACK"93    }94    val sourceStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))95    val noise = try {96        noiseNKInitiator(97            sourceStatic,98            socket::send,99            socket::receive,100        )101    } finally {102        sourceStatic.fill(0)103    }104    try {105        val channel = object : PairingTransport {106            override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))107            override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())108            override suspend fun close() = socket.close()109        }110        val pairState = AdoptionPairState(111            rendezvousSecretHex = Rendezvous.bytesToHex(112                Rendezvous.deriveSecret(noise.handshakeHash),113            ),114            relayUrl = invitation.relayUrl,115        )116        val boundDeps = object : AdoptionReceiverDeps {117            override val deviceName = receiverDeps.deviceName118            override val deviceType = receiverDeps.deviceType119            override val pairState = pairState120            override fun generateDeviceId() = receiverDeps.generateDeviceId()121            override suspend fun confirmSas(): Boolean =122                confirm(SASDerivation.derive(noise.handshakeHash))123124            override suspend fun validateSource(source: AdoptionFrame.Source) {125                receiverDeps.validateSource(source)126            }127128            override suspend fun createVaultFromRootKey(rootKeyHex: String): String =129                receiverDeps.createVaultFromRootKey(rootKeyHex)130131            override suspend fun deriveLocalIdentity(): LocalDeviceIdentity =132                receiverDeps.deriveLocalIdentity()133134            override suspend fun clearPairedDeviceRevocation(deviceId: String) {135                receiverDeps.clearPairedDeviceRevocation(deviceId)136            }137138            override suspend fun upsertPairedDevice(device: PairedDevice) {139                receiverDeps.upsertPairedDevice(device)140            }141142            override suspend fun applyLicense(license: String) {143                receiverDeps.applyLicense(license)144            }145146            override fun onError(error: String) {147                receiverDeps.onError(error)148            }149        }150        return requestAdoption(channel, boundDeps)151    } finally {152        noise.close()153    }154}