RelayAdoptionReceiver.kt

3.9 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 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    try {73        val channel = object : PairingTransport {74            override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))75            override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())76            override suspend fun close() = socket.close()77        }78        val pairState = AdoptionPairState(79            rendezvousSecretHex = Rendezvous.bytesToHex(80                Rendezvous.deriveSecret(noise.handshakeHash),81            ),82            relayUrl = invitation.relayUrl,83        )84        val boundDeps = object : AdoptionReceiverDeps {85            override val deviceName = receiverDeps.deviceName86            override val deviceType = receiverDeps.deviceType87            override val pairState = pairState88            override fun generateDeviceId() = receiverDeps.generateDeviceId()89            override suspend fun confirmSas(): Boolean =90                confirm(SASDerivation.derive(noise.handshakeHash))9192            override suspend fun createVaultFromRootKey(rootKeyHex: String): String =93                receiverDeps.createVaultFromRootKey(rootKeyHex)9495            override suspend fun deriveLocalIdentity(): LocalDeviceIdentity =96                receiverDeps.deriveLocalIdentity()9798            override suspend fun upsertPairedDevice(device: PairedDevice) {99                receiverDeps.upsertPairedDevice(device)100            }101102            override suspend fun applyLicense(license: String) {103                receiverDeps.applyLicense(license)104            }105106            override fun onError(error: String) {107                receiverDeps.onError(error)108            }109        }110        return requestAdoption(channel, boundDeps)111    } finally {112        noise.close()113    }114}