package com.peachpasswords.sync import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.withTimeout import org.json.JSONObject /** * Receiver half of relay-first vault adoption. * * The invitation is reachability only. The relay never sees identity, SAS, or * vault material; those enter only after the Noise NK session is established. */ @Singleton class RelayAdoptionReceiver @Inject constructor() { suspend fun adopt( encodedInvitation: String, deps: AdoptionReceiverDeps, confirm: suspend (sas: String) -> Boolean, windowMs: Long = PAIRING_WINDOW_MS, ): PairedDevice { val invitation = parsePairingInvitation(encodedInvitation) ?: throw IllegalArgumentException("invalid_pairing_invitation") val socket = OkHttpRelaySocket.connect(invitation.roomUrl()) return try { withTimeout(windowMs) { runRelayAdoptionReceiverSession( socket = socket, invitation = invitation, receiverDeps = deps, confirm = confirm, ) ?: throw IllegalStateException("adoption_rejected") } } finally { socket.close() } } } /** * Pure live-session seam used by both production and the real Noise interop * test. The source must already occupy the relay room before this starts. */ internal suspend fun runRelayAdoptionReceiverSession( socket: RelayBinarySocket, invitation: PairingInvitation, receiverDeps: AdoptionReceiverDeps, confirm: suspend (sas: String) -> Boolean, ): PairedDevice? { socket.send( JSONObject() .put("type", "PEACH_HELLO") .put("token", invitation.token) .toString() .toByteArray(Charsets.UTF_8), ) val ack = JSONObject(String(socket.receive(), Charsets.UTF_8)) require(ack.optString("type") == "PEACH_HELLO_ACK") { "expected PEACH_HELLO_ACK" } val sourceStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex")) val noise = try { noiseNKInitiator( sourceStatic, socket::send, socket::receive, ) } finally { sourceStatic.fill(0) } try { val channel = object : PairingTransport { override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg)) override suspend fun receive(): ByteArray = noise.decrypt(socket.receive()) override suspend fun close() = socket.close() } val pairState = AdoptionPairState( rendezvousSecretHex = Rendezvous.bytesToHex( Rendezvous.deriveSecret(noise.handshakeHash), ), relayUrl = invitation.relayUrl, ) val boundDeps = object : AdoptionReceiverDeps { override val deviceName = receiverDeps.deviceName override val deviceType = receiverDeps.deviceType override val pairState = pairState override fun generateDeviceId() = receiverDeps.generateDeviceId() override suspend fun confirmSas(): Boolean = confirm(SASDerivation.derive(noise.handshakeHash)) override suspend fun createVaultFromRootKey(rootKeyHex: String): String = receiverDeps.createVaultFromRootKey(rootKeyHex) override suspend fun deriveLocalIdentity(): LocalChirpIdentity = receiverDeps.deriveLocalIdentity() override suspend fun upsertPairedDevice(device: PairedDevice) { receiverDeps.upsertPairedDevice(device) } override suspend fun applyLicense(license: String) { receiverDeps.applyLicense(license) } override fun onError(error: String) { receiverDeps.onError(error) } } return requestAdoption(channel, boundDeps) } finally { noise.close() } }