RelayAdoptionReceiver.kt

19.5 KB · raw

1package com.peachpasswords.sync23import java.security.MessageDigest4import javax.inject.Inject5import javax.inject.Singleton6import kotlinx.coroutines.CancellationException7import kotlinx.coroutines.withTimeout8import kotlinx.coroutines.withTimeoutOrNull9import org.json.JSONObject10import com.peachpasswords.sync.hexToPubkey1112/**13 * Receiver half of relay-first vault adoption.14 *15 * The invitation is reachability only. The relay never sees identity, SAS, or16 * vault material; those enter only after the Noise NK session is established.17 */18@Singleton19class RelayAdoptionReceiver @Inject constructor(20    private val generation: SyncGenerationPort,21    private val deviceStore: PairedDeviceRegistry,22) {23    /**24     * Phone as receiver: scan a [VaultStatus.POPULATED] invitation, dial the25     * relay room, complete Noise, and receive the source's root key. This is26     * the historical pairing direction and stays byte-for-byte unchanged.27     */28    suspend fun adopt(29        encodedInvitation: String,30        deps: AdoptionReceiverV2Deps,31        confirm: suspend (sas: String) -> Boolean,32        windowMs: Long = PAIRING_WINDOW_MS,33    ): PairedDevice {34        val invitation = parsePairingInvitation(encodedInvitation)35            ?: throw IllegalArgumentException("invalid_pairing_invitation")36        require(invitation.vaultStatus == VaultStatus.POPULATED) {37            "receiver adopt() expects a populated invitation; got ${invitation.vaultStatus}"38        }39        val socket = OkHttpRelaySocket.connectPairing(invitation.roomUrl())40        val adopted = adoptFromRelaySocket(41            socket = socket,42            invitation = invitation,43            deps = deps,44            confirm = confirm,45            windowMs = windowMs,46        )47        markRepairProgress()48        return adopted49    }5051    /**52     * §11.3 REPAIRING → V2_ACTIVE. Any adoption that reaches here while this53     * device is still anchored is a v2 one: the post-reset source refuses v1 at54     * the first frame, and a v1 source cannot answer a v2 request.55     */56    private fun markRepairProgress() {57        onDirectV2PairingEstablished(58            generation,59            hasDirectDevice = deviceStore.getAll().any { it.trustSource == "direct" },60            nowMs = System.currentTimeMillis(),61        )62    }6364    /**65     * Phone as source: scan a [VaultStatus.EMPTY] invitation from an empty66     * device, dial the relay room, complete Noise, and hand our root key over.67     * Symmetric to [adopt]: the initiator runs Noise NK, but the payload68     * direction flips — the phone sends the grant, the empty device receives69     * it.70     */71    suspend fun grantFromInvitation(72        encodedInvitation: String,73        sourceDeps: AdoptionSourceV2Deps,74        confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,75        onPeerOutdated: () -> Unit = {},76        windowMs: Long = PAIRING_WINDOW_MS,77    ): PairedDevice {78        val invitation = parsePairingInvitation(encodedInvitation)79            ?: throw IllegalArgumentException("invalid_pairing_invitation")80        require(invitation.vaultStatus == VaultStatus.EMPTY) {81            "grantFromInvitation() expects an empty invitation; got ${invitation.vaultStatus}"82        }83        val socket = OkHttpRelaySocket.connectPairing(invitation.roomUrl())84        val granted = grantFromRelaySocket(85            socket = socket,86            invitation = invitation,87            sourceDeps = sourceDeps,88            confirm = confirm,89            // §11.6 on this entry point too: the probe lives here rather than90            // being passed in, so neither source session can be built without it.91            postReset = { generation.isPostReset(System.currentTimeMillis()) },92            onPeerOutdated = onPeerOutdated,93            windowMs = windowMs,94        )95        markRepairProgress()96        return granted97    }98}99100/**101 * Complete one receiver session and choose the correct socket shutdown.102 *103 * The final receiver action is an encrypted ADOPT_APPLIED frame. OkHttp's104 * send() only queues it, so a successful session must close gracefully; an105 * immediate cancel can discard that frame after both devices have already106 * persisted one another, leaving the source to report a false failure.107 */108internal suspend fun adoptFromRelaySocket(109    socket: RelayBinarySocket,110    invitation: PairingInvitation,111    deps: AdoptionReceiverV2Deps,112    confirm: suspend (sas: String) -> Boolean,113    windowMs: Long = PAIRING_WINDOW_MS,114): PairedDevice {115    var completed = false116    return try {117        val adopted =118            withTimeout(windowMs) {119                runRelayAdoptionReceiverSession(120                    socket = socket,121                    invitation = invitation,122                    receiverDeps = deps,123                    confirm = confirm,124                ) ?: throw IllegalStateException("adoption_rejected")125            }126        completed = true127        adopted128    } finally {129        if (completed) {130            socket.closeAfterQueuedFrames()131        } else {132            socket.close()133        }134    }135}136137/**138 * Source-initiator counterpart to [adoptFromRelaySocket]: phone dials the139 * relay room, runs Noise as initiator, and hands the root key over to the140 * empty responder. The final source action is an encrypted ADOPT_COMPLETE,141 * so a graceful close is required to land it.142 */143internal suspend fun grantFromRelaySocket(144    socket: RelayBinarySocket,145    invitation: PairingInvitation,146    sourceDeps: AdoptionSourceV2Deps,147    confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,148    postReset: () -> Boolean = { false },149    onPeerOutdated: () -> Unit = {},150    windowMs: Long = PAIRING_WINDOW_MS,151): PairedDevice {152    var completed = false153    return try {154        val granted = withTimeout(windowMs) {155            runRelayAdoptionInitiatorSourceSession(156                socket = socket,157                invitation = invitation,158                sourceDeps = sourceDeps,159                confirm = confirm,160                postReset = postReset,161                onPeerOutdated = onPeerOutdated,162            ) ?: throw IllegalStateException("adoption_rejected")163        }164        completed = true165        granted166    } finally {167        if (completed) {168            socket.closeAfterQueuedFrames()169        } else {170            socket.close()171        }172    }173}174175/**176 * Pure live-session seam used by both production and the real Noise interop177 * test. The source must already occupy the relay room before this starts.178 */179internal suspend fun runRelayAdoptionReceiverSession(180    socket: RelayBinarySocket,181    invitation: PairingInvitation,182    receiverDeps: AdoptionReceiverV2Deps,183    confirm: suspend (sas: String) -> Boolean,184): PairedDevice? {185    socket.send(186        JSONObject()187            .put("type", "PEACH_HELLO")188            .put("token", invitation.token)189            .toString()190            .toByteArray(Charsets.UTF_8),191    )192193    val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))194    require(ack.optString("type") == "PEACH_HELLO_ACK") {195        "expected PEACH_HELLO_ACK"196    }197    val sourceStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))198    val invitedStatic = invitation.noiseStaticPublicKeyHex199        ?.let(::hexToPubkey)200        ?: throw IllegalArgumentException("pairing_invitation_missing_static_key")201    val staticMatches = MessageDigest.isEqual(sourceStatic, invitedStatic)202    invitedStatic.fill(0)203    if (!staticMatches) {204        sourceStatic.fill(0)205        throw IllegalArgumentException("pairing_static_key_mismatch")206    }207    val noise = try {208        noiseNKInitiator(209            sourceStatic,210            socket::send,211            socket::receive,212        )213    } finally {214        sourceStatic.fill(0)215    }216    try {217        val channel = object : PairingTransport {218            override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))219            override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())220            override suspend fun close() = socket.close()221        }222        val pairState = AdoptionPairState(223            rendezvousSecretHex = Rendezvous.bytesToHex(224                Rendezvous.deriveSecret(noise.handshakeHash),225            ),226            relayUrl = invitation.relayUrl,227        )228        val boundDeps = object : AdoptionReceiverV2Deps {229            override val deviceName = receiverDeps.deviceName230            override val deviceType = receiverDeps.deviceType231            override val pairState = pairState232            override fun generateDeviceId() = receiverDeps.generateDeviceId()233            override suspend fun confirmSas(): Boolean =234                confirm(SASDerivation.derive(noise.handshakeHash))235236            // The v2 transcript presents this device's durable identity, which237            // exists before the vault does; the v1 path still derives its own.238            override fun localIdentity(): LocalDeviceIdentity = receiverDeps.localIdentity()239240            override suspend fun rotateLocalIdentity(): LocalDeviceIdentity =241                receiverDeps.rotateLocalIdentity()242243            override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {244                receiverDeps.validateSource(prepare)245            }246247            override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare): Boolean =248                receiverDeps.hasSourceIdentityCollision(prepare)249250            override suspend fun validateSource(source: AdoptionFrame.Source) {251                receiverDeps.validateSource(source)252            }253254            override suspend fun hasSourceIdentityCollision(source: AdoptionFrame.Source): Boolean =255                receiverDeps.hasSourceIdentityCollision(source)256257            override suspend fun createVaultFromRootKey(rootKeyHex: String): String =258                receiverDeps.createVaultFromRootKey(rootKeyHex)259260            override suspend fun deriveLocalIdentity(): LocalDeviceIdentity =261                receiverDeps.deriveLocalIdentity()262            override suspend fun clearPairedDeviceRevocation(deviceId: String) {263                receiverDeps.clearPairedDeviceRevocation(deviceId)264            }265266            override suspend fun upsertPairedDeviceAndClearRevocation(device: PairedDevice) {267                receiverDeps.upsertPairedDeviceAndClearRevocation(device)268            }269270            override suspend fun upsertPairedDevice(device: PairedDevice) {271                receiverDeps.upsertPairedDevice(device)272            }273274            override suspend fun applyLicense(license: String) {275                receiverDeps.applyLicense(license)276            }277278            override suspend fun license(): String? = receiverDeps.license()279280            override fun onError(error: String) {281                receiverDeps.onError(error)282            }283            override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String?) {284                receiverDeps.applySnapshot(entries, tombstones, duressSlot)285            }286            override suspend fun applyIncomingDeviceManifest(287                manifest: SignedDeviceManifest,288                authenticatedRemoteStaticPublicKey: ByteArray,289            ) {290                receiverDeps.applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey)291            }292            override suspend fun markSynced(deviceId: String) {293                receiverDeps.markSynced(deviceId)294            }295        }296        // Capture the source's snapshot capability so we can skip the 15s297        // inline-snapshot wait when the source has nothing to send.298        var sourceCanSendSnapshot = false299        val adopted = requestAdoptionV2(300            channel,301            boundDeps,302            onPrepare = { prepare -> sourceCanSendSnapshot = prepare.snapshotCapable == true },303        ) ?: return null304        if (sourceCanSendSnapshot) {305            // Try to receive the inline vault snapshot.306            try {307                val snapshot = withTimeoutOrNull(15_000) {308                    receiveVaultSnapshot(channel)309                }310                if (snapshot != null) {311                    boundDeps.applySnapshot(snapshot.entries, snapshot.tombstones, snapshot.duressSlot)312                    sendFrame(channel, AdoptionFrame.SnapshotApplied(313                        entriesReceived = snapshot.entries.size,314                        tombstonesReceived = snapshot.tombstones.size,315                    ))316                    // Optional device manifest from source. Apply before acking so317                    // the receiver graduates into the mesh pool on the same318                    // adoption session; a missing or invalid long-term key is319                    // accepted (we still ack) because the graph can arrive via320                    // the regular sync path on the next SYNC_DEVICE_MANIFEST.321                    try {322                        val manifestFrame = withTimeoutOrNull(5_000) { receiveFrame(channel) }323                        if (manifestFrame is AdoptionFrame.DeviceManifestFrame) {324                            val sourceLongTerm = adopted.longTermPublicKeyHex325                            if (sourceLongTerm != null && sourceLongTerm.length == 64) {326                                boundDeps.applyIncomingDeviceManifest(327                                    manifestFrame.manifest,328                                    hexToPubkey(sourceLongTerm),329                                )330                            }331                            sendFrame(channel, AdoptionFrame.DeviceManifestApplied)332                        }333                    } catch (_: Exception) {}334                    boundDeps.markSynced(adopted.deviceId)335                } else {336                    PeachSyncLog.i("adoption: snapshot receive timed out")337                }338            } catch (e: Exception) {339                PeachSyncLog.w("adoption: snapshot receive failed — reason=${relayLogReason(e)}")340            }341        }342        return adopted343    } finally {344        noise.close()345    }346}347348/**349 * Phone-initiator source session for the "empty browser" invitation path.350 * The phone dials the relay room and waits for the empty responder to send351 * ADOPT_REQUEST, then hands over its root key through [grantAdoption]. The352 * SAS gate is the single commit signal — same model as the existing source353 * session — and onConsent is pre-cleared because the user already opted in354 * by scanning the QR.355 */356internal suspend fun runRelayAdoptionInitiatorSourceSession(357    socket: RelayBinarySocket,358    invitation: PairingInvitation,359    sourceDeps: AdoptionSourceV2Deps,360    confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,361    postReset: () -> Boolean = { false },362    onPeerOutdated: () -> Unit = {},363): PairedDevice? {364    socket.send(365        JSONObject()366            .put("type", "PEACH_HELLO")367            .put("token", invitation.token)368            .toString()369            .toByteArray(Charsets.UTF_8),370    )371372    val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))373    require(ack.optString("type") == "PEACH_HELLO_ACK") {374        "expected PEACH_HELLO_ACK"375    }376    // PB-26-103: authenticate the cleartext ACK against the static pinned in the377    // invitation before running Noise. This is the root-key-bearing direction —378    // the phone grants its vault here — so trusting the ACK lets a relay379    // terminate both sessions, present its own key to each side, and collect380    // ADOPT_GRANT.root_key_hex. The mirror check on the receiver path is at381    // runRelayAdoptionReceiverSession.382    val responderStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))383    val invitedStatic = invitation.noiseStaticPublicKeyHex384        ?.let(::hexToPubkey)385        ?: throw IllegalArgumentException("pairing_invitation_missing_static_key")386    val staticMatches = MessageDigest.isEqual(responderStatic, invitedStatic)387    invitedStatic.fill(0)388    if (!staticMatches) {389        responderStatic.fill(0)390        throw IllegalArgumentException("pairing_static_key_mismatch")391    }392    val noise = try {393        noiseNKInitiator(394            responderStatic,395            socket::send,396            socket::receive,397        )398    } finally {399        responderStatic.fill(0)400    }401    try {402        val channel = object : PairingTransport {403            override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))404            override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())405            override suspend fun close() = socket.close()406        }407        val request = receiveFrame(channel)408        if (request !is AdoptionFrame.RequestV2 && !isAdoptRequest(request)) return null409        // §11.6 through the same predicate the QR-display session uses. After a410        // secure reset a v1 peer is refused before the SAS prompt: its identity411        // derives from the legacy root, which is exactly what the reset retires.412        // Without this the rule held on one pairing entry point and not the413        // other (PB-26-117).414        if (request is AdoptionFrame.Request && !allowLegacyV1(postReset())) {415            onPeerOutdated()416            return null417        }418419        val pairState = AdoptionPairState(420            rendezvousSecretHex = Rendezvous.bytesToHex(421                Rendezvous.deriveSecret(noise.handshakeHash),422            ),423            relayUrl = invitation.relayUrl,424        )425        val boundDeps = bindSourceSessionDeps(426            sourceDeps = sourceDeps,427            sourceIdentity = sourceDeps.sourceIdentity,428            pairState = pairState,429            sas = SASDerivation.derive(noise.handshakeHash),430            snapshotCapable = true,431            confirm = confirm,432        )433        // A v2 receiver introduces itself first; a v1 receiver still opens with434        // the label-only ADOPT_REQUEST and is served by the legacy path.435        val adopted = when (request) {436            is AdoptionFrame.RequestV2 -> grantAdoptionV2(channel, request, boundDeps)437            is AdoptionFrame.Request -> {438                if (!confirm(SASDerivation.derive(noise.handshakeHash), request)) return null439                grantAdoption(channel, request, boundDeps)440            }441            else -> null442        } ?: return null443        // If the receiver advertised snapshot support, send the vault inline.444        val receiverWantsSnapshot = when (request) {445            is AdoptionFrame.Request -> request.snapshotCapable == true446            is AdoptionFrame.RequestV2 -> request.snapshotCapable == true447            else -> false448        }449        if (receiverWantsSnapshot) {450            try {451                val snapshot = boundDeps.vaultSnapshot()452                if (snapshot != null) {453                    val duressSlot = runCatching { boundDeps.duressSlotJson() }.getOrNull()454                    sendVaultSnapshot(channel, snapshot.first, snapshot.second, duressSlot)455                    val applied = receiveFrame(channel)456                    if (applied is AdoptionFrame.SnapshotApplied) {457                        val manifest = boundDeps.outgoingDeviceManifest()458                        if (manifest != null) {459                            sendFrame(channel, AdoptionFrame.DeviceManifestFrame(manifest))460                            withTimeoutOrNull(5_000) { receiveFrame(channel) }461                        }462                        boundDeps.markSynced(adopted.deviceId)463                    }464                }465            } catch (error: Exception) {466                if (error is SnapshotDuressFrameTooLargeException) throw error467                if (error is CancellationException) throw error468                PeachSyncLog.w("adoption: snapshot send failed — reason=${relayLogReason(error)}")469            }470        }471        return adopted472    } finally {473        noise.close()474    }475}