RelayAdoptionReceiver.kt

3.8 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 = CodePairingServer.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 try {26            withTimeout(windowMs) {27                runRelayAdoptionReceiverSession(28                    socket = socket,29                    invitation = invitation,30                    receiverDeps = deps,31                    confirm = confirm,32                ) ?: throw IllegalStateException("adoption_rejected")33            }34        } finally {35            socket.close()36        }37    }38}3940/**41 * Pure live-session seam used by both production and the real Noise interop42 * test. The source must already occupy the relay room before this starts.43 */44internal suspend fun runRelayAdoptionReceiverSession(45    socket: RelayBinarySocket,46    invitation: PairingInvitation,47    receiverDeps: AdoptionReceiverDeps,48    confirm: suspend (sas: String) -> Boolean,49): PairedDevice? {50    socket.send(51        JSONObject()52            .put("type", "PEACH_HELLO")53            .put("token", invitation.token)54            .toString()55            .toByteArray(Charsets.UTF_8),56    )5758    val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))59    require(ack.optString("type") == "PEACH_HELLO_ACK") {60        "expected PEACH_HELLO_ACK"61    }62    val sourceStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))63    val noise = try {64        noiseNKInitiator(65            sourceStatic,66            socket::send,67            socket::receive,68        )69    } finally {70        sourceStatic.fill(0)71    }72    val channel = object : PairingTransport {73        override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))74        override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())75        override suspend fun close() = socket.close()76    }77    val pairState = AdoptionPairState(78        rendezvousSecretHex = Rendezvous.bytesToHex(79            Rendezvous.deriveSecret(noise.handshakeHash),80        ),81        relayUrl = invitation.relayUrl,82    )83    val boundDeps = object : AdoptionReceiverDeps {84        override val deviceName = receiverDeps.deviceName85        override val deviceType = receiverDeps.deviceType86        override val pairState = pairState87        override fun generateDeviceId() = receiverDeps.generateDeviceId()88        override suspend fun confirmSas(): Boolean =89            confirm(SASDerivation.derive(noise.handshakeHash))9091        override suspend fun createVaultFromRootKey(rootKeyHex: String): String =92            receiverDeps.createVaultFromRootKey(rootKeyHex)9394        override suspend fun deriveLocalIdentity(): LocalChirpIdentity =95            receiverDeps.deriveLocalIdentity()9697        override suspend fun upsertPairedDevice(device: PairedDevice) {98            receiverDeps.upsertPairedDevice(device)99        }100101        override suspend fun applyLicense(license: String) {102            receiverDeps.applyLicense(license)103        }104105        override fun onError(error: String) {106            receiverDeps.onError(error)107        }108    }109    return requestAdoption(channel, boundDeps)110}