RelayAdoptionReceiver.kt

17.0 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    /**21     * Phone as receiver: scan a [VaultStatus.POPULATED] invitation, dial the22     * relay room, complete Noise, and receive the source's root key. This is23     * the historical pairing direction and stays byte-for-byte unchanged.24     */25    suspend fun adopt(26        encodedInvitation: String,27        deps: AdoptionReceiverDeps,28        confirm: suspend (sas: String) -> Boolean,29        windowMs: Long = PAIRING_WINDOW_MS,30    ): PairedDevice {31        val invitation = parsePairingInvitation(encodedInvitation)32            ?: throw IllegalArgumentException("invalid_pairing_invitation")33        require(invitation.vaultStatus == VaultStatus.POPULATED) {34            "receiver adopt() expects a populated invitation; got ${invitation.vaultStatus}"35        }36        val socket = OkHttpRelaySocket.connectPairing(invitation.roomUrl())37        return adoptFromRelaySocket(38            socket = socket,39            invitation = invitation,40            deps = deps,41            confirm = confirm,42            windowMs = windowMs,43        )44    }4546    /**47     * Phone as source: scan a [VaultStatus.EMPTY] invitation from an empty48     * device, dial the relay room, complete Noise, and hand our root key over.49     * Symmetric to [adopt]: the initiator runs Noise NK, but the payload50     * direction flips — the phone sends the grant, the empty device receives51     * it.52     */53    suspend fun grantFromInvitation(54        encodedInvitation: String,55        sourceDeps: AdoptionSourceDeps,56        confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,57        windowMs: Long = PAIRING_WINDOW_MS,58    ): PairedDevice {59        val invitation = parsePairingInvitation(encodedInvitation)60            ?: throw IllegalArgumentException("invalid_pairing_invitation")61        require(invitation.vaultStatus == VaultStatus.EMPTY) {62            "grantFromInvitation() expects an empty invitation; got ${invitation.vaultStatus}"63        }64        val socket = OkHttpRelaySocket.connectPairing(invitation.roomUrl())65        return grantFromRelaySocket(66            socket = socket,67            invitation = invitation,68            sourceDeps = sourceDeps,69            confirm = confirm,70            windowMs = windowMs,71        )72    }73}7475/**76 * Complete one receiver session and choose the correct socket shutdown.77 *78 * The final receiver action is an encrypted ADOPT_APPLIED frame. OkHttp's79 * send() only queues it, so a successful session must close gracefully; an80 * immediate cancel can discard that frame after both devices have already81 * persisted one another, leaving the source to report a false failure.82 */83internal suspend fun adoptFromRelaySocket(84    socket: RelayBinarySocket,85    invitation: PairingInvitation,86    deps: AdoptionReceiverDeps,87    confirm: suspend (sas: String) -> Boolean,88    windowMs: Long = PAIRING_WINDOW_MS,89): PairedDevice {90    var completed = false91    return try {92        val adopted =93            withTimeout(windowMs) {94                runRelayAdoptionReceiverSession(95                    socket = socket,96                    invitation = invitation,97                    receiverDeps = deps,98                    confirm = confirm,99                ) ?: throw IllegalStateException("adoption_rejected")100            }101        completed = true102        adopted103    } finally {104        if (completed) {105            socket.closeAfterQueuedFrames()106        } else {107            socket.close()108        }109    }110}111112/**113 * Source-initiator counterpart to [adoptFromRelaySocket]: phone dials the114 * relay room, runs Noise as initiator, and hands the root key over to the115 * empty responder. The final source action is an encrypted ADOPT_COMPLETE,116 * so a graceful close is required to land it.117 */118internal suspend fun grantFromRelaySocket(119    socket: RelayBinarySocket,120    invitation: PairingInvitation,121    sourceDeps: AdoptionSourceDeps,122    confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,123    windowMs: Long = PAIRING_WINDOW_MS,124): PairedDevice {125    var completed = false126    return try {127        val granted = withTimeout(windowMs) {128            runRelayAdoptionInitiatorSourceSession(129                socket = socket,130                invitation = invitation,131                sourceDeps = sourceDeps,132                confirm = confirm,133            ) ?: throw IllegalStateException("adoption_rejected")134        }135        completed = true136        granted137    } finally {138        if (completed) {139            socket.closeAfterQueuedFrames()140        } else {141            socket.close()142        }143    }144}145146/**147 * Pure live-session seam used by both production and the real Noise interop148 * test. The source must already occupy the relay room before this starts.149 */150internal suspend fun runRelayAdoptionReceiverSession(151    socket: RelayBinarySocket,152    invitation: PairingInvitation,153    receiverDeps: AdoptionReceiverDeps,154    confirm: suspend (sas: String) -> Boolean,155): PairedDevice? {156    socket.send(157        JSONObject()158            .put("type", "PEACH_HELLO")159            .put("token", invitation.token)160            .toString()161            .toByteArray(Charsets.UTF_8),162    )163164    val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))165    require(ack.optString("type") == "PEACH_HELLO_ACK") {166        "expected PEACH_HELLO_ACK"167    }168    val sourceStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))169    val invitedStatic = invitation.noiseStaticPublicKeyHex170        ?.let(::hexToPubkey)171        ?: throw IllegalArgumentException("pairing_invitation_missing_static_key")172    val staticMatches = MessageDigest.isEqual(sourceStatic, invitedStatic)173    invitedStatic.fill(0)174    if (!staticMatches) {175        sourceStatic.fill(0)176        throw IllegalArgumentException("pairing_static_key_mismatch")177    }178    val noise = try {179        noiseNKInitiator(180            sourceStatic,181            socket::send,182            socket::receive,183        )184    } finally {185        sourceStatic.fill(0)186    }187    try {188        val channel = object : PairingTransport {189            override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))190            override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())191            override suspend fun close() = socket.close()192        }193        val pairState = AdoptionPairState(194            rendezvousSecretHex = Rendezvous.bytesToHex(195                Rendezvous.deriveSecret(noise.handshakeHash),196            ),197            relayUrl = invitation.relayUrl,198        )199        val boundDeps = object : AdoptionReceiverDeps {200            override val deviceName = receiverDeps.deviceName201            override val deviceType = receiverDeps.deviceType202            override val pairState = pairState203            override fun generateDeviceId() = receiverDeps.generateDeviceId()204            override suspend fun confirmSas(): Boolean =205                confirm(SASDerivation.derive(noise.handshakeHash))206207            override suspend fun validateSource(source: AdoptionFrame.Source) {208                receiverDeps.validateSource(source)209            }210211            override suspend fun createVaultFromRootKey(rootKeyHex: String): String =212                receiverDeps.createVaultFromRootKey(rootKeyHex)213214            override suspend fun deriveLocalIdentity(): LocalDeviceIdentity =215                receiverDeps.deriveLocalIdentity()216217            override suspend fun clearPairedDeviceRevocation(deviceId: String) {218                receiverDeps.clearPairedDeviceRevocation(deviceId)219            }220221            override suspend fun upsertPairedDevice(device: PairedDevice) {222                receiverDeps.upsertPairedDevice(device)223            }224225            override suspend fun applyLicense(license: String) {226                receiverDeps.applyLicense(license)227            }228229            override suspend fun license(): String? = receiverDeps.license()230231            override fun onError(error: String) {232                receiverDeps.onError(error)233            }234            override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String?) {235                receiverDeps.applySnapshot(entries, tombstones, duressSlot)236            }237            override suspend fun applyIncomingDeviceManifest(238                manifest: SignedDeviceManifest,239                authenticatedRemoteStaticPublicKey: ByteArray,240            ) {241                receiverDeps.applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey)242            }243            override suspend fun markSynced(deviceId: String) {244                receiverDeps.markSynced(deviceId)245            }246        }247        // Capture the source's ADOPT_GRANT.snapshot_capable echo so we can skip248        // the 15s inline-snapshot wait when the source has nothing to send.249        // Default false = "pre-C source, no echo" = skip the wait.250        var sourceCanSendSnapshot = false251        val adopted = requestAdoption(252            channel,253            boundDeps,254            onGrant = { grant -> sourceCanSendSnapshot = grant.snapshotCapable },255        ) ?: return null256        if (sourceCanSendSnapshot) {257            // Try to receive the inline vault snapshot.258            try {259                val snapshot = withTimeoutOrNull(15_000) {260                    receiveVaultSnapshot(channel)261                }262                if (snapshot != null) {263                    boundDeps.applySnapshot(snapshot.entries, snapshot.tombstones, snapshot.duressSlot)264                    sendFrame(channel, AdoptionFrame.SnapshotApplied(265                        entriesReceived = snapshot.entries.size,266                        tombstonesReceived = snapshot.tombstones.size,267                    ))268                    // Optional device manifest from source. Apply before acking so269                    // the receiver graduates into the mesh pool on the same270                    // adoption session; a missing or invalid long-term key is271                    // accepted (we still ack) because the graph can arrive via272                    // the regular sync path on the next SYNC_DEVICE_MANIFEST.273                    try {274                        val manifestFrame = withTimeoutOrNull(5_000) { receiveFrame(channel) }275                        if (manifestFrame is AdoptionFrame.DeviceManifestFrame) {276                            val sourceLongTerm = adopted.longTermPublicKeyHex277                            if (sourceLongTerm != null && sourceLongTerm.length == 64) {278                                boundDeps.applyIncomingDeviceManifest(279                                    manifestFrame.manifest,280                                    hexToPubkey(sourceLongTerm),281                                )282                            }283                            sendFrame(channel, AdoptionFrame.DeviceManifestApplied)284                        }285                    } catch (_: Exception) {}286                    boundDeps.markSynced(adopted.deviceId)287                } else {288                    PeachSyncLog.i("adoption: snapshot receive timed out")289                }290            } catch (e: Exception) {291                PeachSyncLog.w("adoption: snapshot receive failed — reason=${relayLogReason(e)}")292            }293        }294        return adopted295    } finally {296        noise.close()297    }298}299300/**301 * Phone-initiator source session for the "empty browser" invitation path.302 * The phone dials the relay room and waits for the empty responder to send303 * ADOPT_REQUEST, then hands over its root key through [grantAdoption]. The304 * SAS gate is the single commit signal — same model as the existing source305 * session — and onConsent is pre-cleared because the user already opted in306 * by scanning the QR.307 */308internal suspend fun runRelayAdoptionInitiatorSourceSession(309    socket: RelayBinarySocket,310    invitation: PairingInvitation,311    sourceDeps: AdoptionSourceDeps,312    confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,313): PairedDevice? {314    socket.send(315        JSONObject()316            .put("type", "PEACH_HELLO")317            .put("token", invitation.token)318            .toString()319            .toByteArray(Charsets.UTF_8),320    )321322    val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))323    require(ack.optString("type") == "PEACH_HELLO_ACK") {324        "expected PEACH_HELLO_ACK"325    }326    // PB-26-103: authenticate the cleartext ACK against the static pinned in the327    // invitation before running Noise. This is the root-key-bearing direction —328    // the phone grants its vault here — so trusting the ACK lets a relay329    // terminate both sessions, present its own key to each side, and collect330    // ADOPT_GRANT.root_key_hex. The mirror check on the receiver path is at331    // runRelayAdoptionReceiverSession.332    val responderStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))333    val invitedStatic = invitation.noiseStaticPublicKeyHex334        ?.let(::hexToPubkey)335        ?: throw IllegalArgumentException("pairing_invitation_missing_static_key")336    val staticMatches = MessageDigest.isEqual(responderStatic, invitedStatic)337    invitedStatic.fill(0)338    if (!staticMatches) {339        responderStatic.fill(0)340        throw IllegalArgumentException("pairing_static_key_mismatch")341    }342    val noise = try {343        noiseNKInitiator(344            responderStatic,345            socket::send,346            socket::receive,347        )348    } finally {349        responderStatic.fill(0)350    }351    try {352        val channel = object : PairingTransport {353            override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))354            override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())355            override suspend fun close() = socket.close()356        }357        val request = receiveFrame(channel)358        if (!isAdoptRequest(request)) return null359        request as AdoptionFrame.Request360        if (!confirm(SASDerivation.derive(noise.handshakeHash), request)) return null361362        val pairState = AdoptionPairState(363            rendezvousSecretHex = Rendezvous.bytesToHex(364                Rendezvous.deriveSecret(noise.handshakeHash),365            ),366            relayUrl = invitation.relayUrl,367        )368        val boundDeps = object : AdoptionSourceDeps {369            override val sourceIdentity = sourceDeps.sourceIdentity370            override val pairState = pairState371            override suspend fun rootKeyHex(): String = sourceDeps.rootKeyHex()372            override suspend fun vaultId(): String = sourceDeps.vaultId()373            override suspend fun license(): String? = sourceDeps.license()374            override suspend fun applyLicense(license: String) {375                sourceDeps.applyLicense(license)376            }377            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true378            override suspend fun upsertPairedDevice(device: PairedDevice) {379                sourceDeps.upsertPairedDevice(device)380            }381            override suspend fun vaultSnapshot() = sourceDeps.vaultSnapshot()382            override suspend fun outgoingDeviceManifest() = sourceDeps.outgoingDeviceManifest()383            override suspend fun markSynced(deviceId: String) = sourceDeps.markSynced(deviceId)384            override suspend fun duressSlotJson() = sourceDeps.duressSlotJson()385        }386        val adopted = grantAdoption(channel, request, boundDeps) ?: return null387        // If the receiver advertised snapshot support, send the vault inline.388        if (request.snapshotCapable == true) {389            try {390                val snapshot = boundDeps.vaultSnapshot()391                if (snapshot != null) {392                    val duressSlot = runCatching { boundDeps.duressSlotJson() }.getOrNull()393                    sendVaultSnapshot(channel, snapshot.first, snapshot.second, duressSlot)394                    val applied = receiveFrame(channel)395                    if (applied is AdoptionFrame.SnapshotApplied) {396                        val manifest = boundDeps.outgoingDeviceManifest()397                        if (manifest != null) {398                            sendFrame(channel, AdoptionFrame.DeviceManifestFrame(manifest))399                            withTimeoutOrNull(5_000) { receiveFrame(channel) }400                        }401                        boundDeps.markSynced(adopted.deviceId)402                    }403                }404            } catch (error: Exception) {405                if (error is SnapshotDuressFrameTooLargeException) throw error406                if (error is CancellationException) throw error407                PeachSyncLog.w("adoption: snapshot send failed — reason=${relayLogReason(error)}")408            }409        }410        return adopted411    } finally {412        noise.close()413    }414}