RelayAdoptionReceiver.kt

10.6 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    /**17     * Phone as receiver: scan a [VaultStatus.POPULATED] invitation, dial the18     * relay room, complete Noise, and receive the source's root key. This is19     * the historical pairing direction and stays byte-for-byte unchanged.20     */21    suspend fun adopt(22        encodedInvitation: String,23        deps: AdoptionReceiverDeps,24        confirm: suspend (sas: String) -> Boolean,25        windowMs: Long = PAIRING_WINDOW_MS,26    ): PairedDevice {27        val invitation = parsePairingInvitation(encodedInvitation)28            ?: throw IllegalArgumentException("invalid_pairing_invitation")29        require(invitation.vaultStatus == VaultStatus.POPULATED) {30            "receiver adopt() expects a populated invitation; got ${invitation.vaultStatus}"31        }32        val socket = OkHttpRelaySocket.connect(invitation.roomUrl())33        return adoptFromRelaySocket(34            socket = socket,35            invitation = invitation,36            deps = deps,37            confirm = confirm,38            windowMs = windowMs,39        )40    }4142    /**43     * Phone as source: scan a [VaultStatus.EMPTY] invitation from an empty44     * device, dial the relay room, complete Noise, and hand our root key over.45     * Symmetric to [adopt]: the initiator runs Noise NK, but the payload46     * direction flips — the phone sends the grant, the empty device receives47     * it.48     */49    suspend fun grantFromInvitation(50        encodedInvitation: String,51        sourceDeps: AdoptionSourceDeps,52        confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,53        windowMs: Long = PAIRING_WINDOW_MS,54    ): PairedDevice {55        val invitation = parsePairingInvitation(encodedInvitation)56            ?: throw IllegalArgumentException("invalid_pairing_invitation")57        require(invitation.vaultStatus == VaultStatus.EMPTY) {58            "grantFromInvitation() expects an empty invitation; got ${invitation.vaultStatus}"59        }60        val socket = OkHttpRelaySocket.connect(invitation.roomUrl())61        return grantFromRelaySocket(62            socket = socket,63            invitation = invitation,64            sourceDeps = sourceDeps,65            confirm = confirm,66            windowMs = windowMs,67        )68    }69}7071/**72 * Complete one receiver session and choose the correct socket shutdown.73 *74 * The final receiver action is an encrypted ADOPT_APPLIED frame. OkHttp's75 * send() only queues it, so a successful session must close gracefully; an76 * immediate cancel can discard that frame after both devices have already77 * persisted one another, leaving the source to report a false failure.78 */79internal suspend fun adoptFromRelaySocket(80    socket: RelayBinarySocket,81    invitation: PairingInvitation,82    deps: AdoptionReceiverDeps,83    confirm: suspend (sas: String) -> Boolean,84    windowMs: Long = PAIRING_WINDOW_MS,85): PairedDevice {86    var completed = false87    return try {88        val adopted =89            withTimeout(windowMs) {90                runRelayAdoptionReceiverSession(91                    socket = socket,92                    invitation = invitation,93                    receiverDeps = deps,94                    confirm = confirm,95                ) ?: throw IllegalStateException("adoption_rejected")96            }97        completed = true98        adopted99    } finally {100        if (completed) {101            socket.closeAfterQueuedFrames()102        } else {103            socket.close()104        }105    }106}107108/**109 * Source-initiator counterpart to [adoptFromRelaySocket]: phone dials the110 * relay room, runs Noise as initiator, and hands the root key over to the111 * empty responder. The final source action is an encrypted ADOPT_COMPLETE,112 * so a graceful close is required to land it.113 */114internal suspend fun grantFromRelaySocket(115    socket: RelayBinarySocket,116    invitation: PairingInvitation,117    sourceDeps: AdoptionSourceDeps,118    confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,119    windowMs: Long = PAIRING_WINDOW_MS,120): PairedDevice {121    var completed = false122    return try {123        val granted = withTimeout(windowMs) {124            runRelayAdoptionInitiatorSourceSession(125                socket = socket,126                invitation = invitation,127                sourceDeps = sourceDeps,128                confirm = confirm,129            ) ?: throw IllegalStateException("adoption_rejected")130        }131        completed = true132        granted133    } finally {134        if (completed) {135            socket.closeAfterQueuedFrames()136        } else {137            socket.close()138        }139    }140}141142/**143 * Pure live-session seam used by both production and the real Noise interop144 * test. The source must already occupy the relay room before this starts.145 */146internal suspend fun runRelayAdoptionReceiverSession(147    socket: RelayBinarySocket,148    invitation: PairingInvitation,149    receiverDeps: AdoptionReceiverDeps,150    confirm: suspend (sas: String) -> Boolean,151): PairedDevice? {152    socket.send(153        JSONObject()154            .put("type", "PEACH_HELLO")155            .put("token", invitation.token)156            .toString()157            .toByteArray(Charsets.UTF_8),158    )159160    val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))161    require(ack.optString("type") == "PEACH_HELLO_ACK") {162        "expected PEACH_HELLO_ACK"163    }164    val sourceStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))165    val noise = try {166        noiseNKInitiator(167            sourceStatic,168            socket::send,169            socket::receive,170        )171    } finally {172        sourceStatic.fill(0)173    }174    try {175        val channel = object : PairingTransport {176            override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))177            override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())178            override suspend fun close() = socket.close()179        }180        val pairState = AdoptionPairState(181            rendezvousSecretHex = Rendezvous.bytesToHex(182                Rendezvous.deriveSecret(noise.handshakeHash),183            ),184            relayUrl = invitation.relayUrl,185        )186        val boundDeps = object : AdoptionReceiverDeps {187            override val deviceName = receiverDeps.deviceName188            override val deviceType = receiverDeps.deviceType189            override val pairState = pairState190            override fun generateDeviceId() = receiverDeps.generateDeviceId()191            override suspend fun confirmSas(): Boolean =192                confirm(SASDerivation.derive(noise.handshakeHash))193194            override suspend fun validateSource(source: AdoptionFrame.Source) {195                receiverDeps.validateSource(source)196            }197198            override suspend fun createVaultFromRootKey(rootKeyHex: String): String =199                receiverDeps.createVaultFromRootKey(rootKeyHex)200201            override suspend fun deriveLocalIdentity(): LocalDeviceIdentity =202                receiverDeps.deriveLocalIdentity()203204            override suspend fun clearPairedDeviceRevocation(deviceId: String) {205                receiverDeps.clearPairedDeviceRevocation(deviceId)206            }207208            override suspend fun upsertPairedDevice(device: PairedDevice) {209                receiverDeps.upsertPairedDevice(device)210            }211212            override suspend fun applyLicense(license: String) {213                receiverDeps.applyLicense(license)214            }215216            override fun onError(error: String) {217                receiverDeps.onError(error)218            }219        }220        return requestAdoption(channel, boundDeps)221    } finally {222        noise.close()223    }224}225226/**227 * Phone-initiator source session for the "empty browser" invitation path.228 * The phone dials the relay room and waits for the empty responder to send229 * ADOPT_REQUEST, then hands over its root key through [grantAdoption]. The230 * SAS gate is the single commit signal — same model as the existing source231 * session — and onConsent is pre-cleared because the user already opted in232 * by scanning the QR.233 */234internal suspend fun runRelayAdoptionInitiatorSourceSession(235    socket: RelayBinarySocket,236    invitation: PairingInvitation,237    sourceDeps: AdoptionSourceDeps,238    confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,239): PairedDevice? {240    socket.send(241        JSONObject()242            .put("type", "PEACH_HELLO")243            .put("token", invitation.token)244            .toString()245            .toByteArray(Charsets.UTF_8),246    )247248    val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))249    require(ack.optString("type") == "PEACH_HELLO_ACK") {250        "expected PEACH_HELLO_ACK"251    }252    val responderStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))253    val noise = try {254        noiseNKInitiator(255            responderStatic,256            socket::send,257            socket::receive,258        )259    } finally {260        responderStatic.fill(0)261    }262    try {263        val channel = object : PairingTransport {264            override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))265            override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())266            override suspend fun close() = socket.close()267        }268        val request = receiveFrame(channel)269        if (!isAdoptRequest(request)) return null270        request as AdoptionFrame.Request271        if (!confirm(SASDerivation.derive(noise.handshakeHash), request)) return null272273        val pairState = AdoptionPairState(274            rendezvousSecretHex = Rendezvous.bytesToHex(275                Rendezvous.deriveSecret(noise.handshakeHash),276            ),277            relayUrl = invitation.relayUrl,278        )279        val boundDeps = object : AdoptionSourceDeps {280            override val sourceIdentity = sourceDeps.sourceIdentity281            override val pairState = pairState282            override suspend fun rootKeyHex(): String = sourceDeps.rootKeyHex()283            override suspend fun vaultId(): String = sourceDeps.vaultId()284            override suspend fun license(): String? = sourceDeps.license()285            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true286            override suspend fun upsertPairedDevice(device: PairedDevice) {287                sourceDeps.upsertPairedDevice(device)288            }289        }290        return grantAdoption(channel, request, boundDeps)291    } finally {292        noise.close()293    }294}