RelayAdoptionReceiver.kt

17.1 KB · raw

1package com.peachpasswords.sync23import android.util.Log4import java.security.MessageDigest5import javax.inject.Inject6import javax.inject.Singleton7import kotlinx.coroutines.CancellationException8import kotlinx.coroutines.withTimeout9import kotlinx.coroutines.withTimeoutOrNull10import org.json.JSONObject11import com.peachpasswords.sync.hexToPubkey1213/**14 * Receiver half of relay-first vault adoption.15 *16 * The invitation is reachability only. The relay never sees identity, SAS, or17 * vault material; those enter only after the Noise NK session is established.18 */19@Singleton20class RelayAdoptionReceiver @Inject constructor() {21    /**22     * Phone as receiver: scan a [VaultStatus.POPULATED] invitation, dial the23     * relay room, complete Noise, and receive the source's root key. This is24     * the historical pairing direction and stays byte-for-byte unchanged.25     */26    suspend fun adopt(27        encodedInvitation: String,28        deps: AdoptionReceiverDeps,29        confirm: suspend (sas: String) -> Boolean,30        windowMs: Long = PAIRING_WINDOW_MS,31    ): PairedDevice {32        val invitation = parsePairingInvitation(encodedInvitation)33            ?: throw IllegalArgumentException("invalid_pairing_invitation")34        require(invitation.vaultStatus == VaultStatus.POPULATED) {35            "receiver adopt() expects a populated invitation; got ${invitation.vaultStatus}"36        }37        val socket = OkHttpRelaySocket.connect(invitation.roomUrl())38        return adoptFromRelaySocket(39            socket = socket,40            invitation = invitation,41            deps = deps,42            confirm = confirm,43            windowMs = windowMs,44        )45    }4647    /**48     * Phone as source: scan a [VaultStatus.EMPTY] invitation from an empty49     * device, dial the relay room, complete Noise, and hand our root key over.50     * Symmetric to [adopt]: the initiator runs Noise NK, but the payload51     * direction flips — the phone sends the grant, the empty device receives52     * it.53     */54    suspend fun grantFromInvitation(55        encodedInvitation: String,56        sourceDeps: AdoptionSourceDeps,57        confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,58        windowMs: Long = PAIRING_WINDOW_MS,59    ): PairedDevice {60        val invitation = parsePairingInvitation(encodedInvitation)61            ?: throw IllegalArgumentException("invalid_pairing_invitation")62        require(invitation.vaultStatus == VaultStatus.EMPTY) {63            "grantFromInvitation() expects an empty invitation; got ${invitation.vaultStatus}"64        }65        val socket = OkHttpRelaySocket.connect(invitation.roomUrl())66        return grantFromRelaySocket(67            socket = socket,68            invitation = invitation,69            sourceDeps = sourceDeps,70            confirm = confirm,71            windowMs = windowMs,72        )73    }74}7576/**77 * Complete one receiver session and choose the correct socket shutdown.78 *79 * The final receiver action is an encrypted ADOPT_APPLIED frame. OkHttp's80 * send() only queues it, so a successful session must close gracefully; an81 * immediate cancel can discard that frame after both devices have already82 * persisted one another, leaving the source to report a false failure.83 */84internal suspend fun adoptFromRelaySocket(85    socket: RelayBinarySocket,86    invitation: PairingInvitation,87    deps: AdoptionReceiverDeps,88    confirm: suspend (sas: String) -> Boolean,89    windowMs: Long = PAIRING_WINDOW_MS,90): PairedDevice {91    var completed = false92    return try {93        val adopted =94            withTimeout(windowMs) {95                runRelayAdoptionReceiverSession(96                    socket = socket,97                    invitation = invitation,98                    receiverDeps = deps,99                    confirm = confirm,100                ) ?: throw IllegalStateException("adoption_rejected")101            }102        completed = true103        adopted104    } finally {105        if (completed) {106            socket.closeAfterQueuedFrames()107        } else {108            socket.close()109        }110    }111}112113/**114 * Source-initiator counterpart to [adoptFromRelaySocket]: phone dials the115 * relay room, runs Noise as initiator, and hands the root key over to the116 * empty responder. The final source action is an encrypted ADOPT_COMPLETE,117 * so a graceful close is required to land it.118 */119internal suspend fun grantFromRelaySocket(120    socket: RelayBinarySocket,121    invitation: PairingInvitation,122    sourceDeps: AdoptionSourceDeps,123    confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,124    windowMs: Long = PAIRING_WINDOW_MS,125): PairedDevice {126    var completed = false127    return try {128        val granted = withTimeout(windowMs) {129            runRelayAdoptionInitiatorSourceSession(130                socket = socket,131                invitation = invitation,132                sourceDeps = sourceDeps,133                confirm = confirm,134            ) ?: throw IllegalStateException("adoption_rejected")135        }136        completed = true137        granted138    } finally {139        if (completed) {140            socket.closeAfterQueuedFrames()141        } else {142            socket.close()143        }144    }145}146147/**148 * Pure live-session seam used by both production and the real Noise interop149 * test. The source must already occupy the relay room before this starts.150 */151internal suspend fun runRelayAdoptionReceiverSession(152    socket: RelayBinarySocket,153    invitation: PairingInvitation,154    receiverDeps: AdoptionReceiverDeps,155    confirm: suspend (sas: String) -> Boolean,156): PairedDevice? {157    socket.send(158        JSONObject()159            .put("type", "PEACH_HELLO")160            .put("token", invitation.token)161            .toString()162            .toByteArray(Charsets.UTF_8),163    )164165    val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))166    require(ack.optString("type") == "PEACH_HELLO_ACK") {167        "expected PEACH_HELLO_ACK"168    }169    val sourceStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))170    val invitedStatic = invitation.noiseStaticPublicKeyHex171        ?.let(::hexToPubkey)172        ?: throw IllegalArgumentException("pairing_invitation_missing_static_key")173    val staticMatches = MessageDigest.isEqual(sourceStatic, invitedStatic)174    invitedStatic.fill(0)175    if (!staticMatches) {176        sourceStatic.fill(0)177        throw IllegalArgumentException("pairing_static_key_mismatch")178    }179    val noise = try {180        noiseNKInitiator(181            sourceStatic,182            socket::send,183            socket::receive,184        )185    } finally {186        sourceStatic.fill(0)187    }188    try {189        val channel = object : PairingTransport {190            override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))191            override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())192            override suspend fun close() = socket.close()193        }194        val pairState = AdoptionPairState(195            rendezvousSecretHex = Rendezvous.bytesToHex(196                Rendezvous.deriveSecret(noise.handshakeHash),197            ),198            relayUrl = invitation.relayUrl,199        )200        val boundDeps = object : AdoptionReceiverDeps {201            override val deviceName = receiverDeps.deviceName202            override val deviceType = receiverDeps.deviceType203            override val pairState = pairState204            override fun generateDeviceId() = receiverDeps.generateDeviceId()205            override suspend fun confirmSas(): Boolean =206                confirm(SASDerivation.derive(noise.handshakeHash))207208            override suspend fun validateSource(source: AdoptionFrame.Source) {209                receiverDeps.validateSource(source)210            }211212            override suspend fun createVaultFromRootKey(rootKeyHex: String): String =213                receiverDeps.createVaultFromRootKey(rootKeyHex)214215            override suspend fun deriveLocalIdentity(): LocalDeviceIdentity =216                receiverDeps.deriveLocalIdentity()217218            override suspend fun clearPairedDeviceRevocation(deviceId: String) {219                receiverDeps.clearPairedDeviceRevocation(deviceId)220            }221222            override suspend fun upsertPairedDevice(device: PairedDevice) {223                receiverDeps.upsertPairedDevice(device)224            }225226            override suspend fun applyLicense(license: String) {227                receiverDeps.applyLicense(license)228            }229230            override suspend fun license(): String? = receiverDeps.license()231232            override fun onError(error: String) {233                receiverDeps.onError(error)234            }235            override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String?) {236                receiverDeps.applySnapshot(entries, tombstones, duressSlot)237            }238            override suspend fun applyIncomingDeviceManifest(239                manifest: SignedDeviceManifest,240                authenticatedRemoteStaticPublicKey: ByteArray,241            ) {242                receiverDeps.applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey)243            }244            override suspend fun markSynced(deviceId: String) {245                receiverDeps.markSynced(deviceId)246            }247        }248        // Capture the source's ADOPT_GRANT.snapshot_capable echo so we can skip249        // the 15s inline-snapshot wait when the source has nothing to send.250        // Default false = "pre-C source, no echo" = skip the wait.251        var sourceCanSendSnapshot = false252        val adopted = requestAdoption(253            channel,254            boundDeps,255            onGrant = { grant -> sourceCanSendSnapshot = grant.snapshotCapable },256        ) ?: return null257        if (sourceCanSendSnapshot) {258            // Try to receive the inline vault snapshot.259            try {260                val snapshot = withTimeoutOrNull(15_000) {261                    receiveVaultSnapshot(channel)262                }263                if (snapshot != null) {264                    boundDeps.applySnapshot(snapshot.entries, snapshot.tombstones, snapshot.duressSlot)265                    sendFrame(channel, AdoptionFrame.SnapshotApplied(266                        entriesReceived = snapshot.entries.size,267                        tombstonesReceived = snapshot.tombstones.size,268                    ))269                    // Optional device manifest from source. Apply before acking so270                    // the receiver graduates into the mesh pool on the same271                    // adoption session; a missing or invalid long-term key is272                    // accepted (we still ack) because the graph can arrive via273                    // the regular sync path on the next SYNC_DEVICE_MANIFEST.274                    try {275                        val manifestFrame = withTimeoutOrNull(5_000) { receiveFrame(channel) }276                        if (manifestFrame is AdoptionFrame.DeviceManifestFrame) {277                            val sourceLongTerm = adopted.longTermPublicKeyHex278                            if (sourceLongTerm != null && sourceLongTerm.length == 64) {279                                boundDeps.applyIncomingDeviceManifest(280                                    manifestFrame.manifest,281                                    hexToPubkey(sourceLongTerm),282                                )283                            }284                            sendFrame(channel, AdoptionFrame.DeviceManifestApplied)285                        }286                    } catch (_: Exception) {}287                    boundDeps.markSynced(adopted.deviceId)288                } else {289                    Log.i("PeachAdopt", "receiver inline snapshot timed out, falling back to sync")290                }291            } catch (e: Exception) {292                Log.w("PeachAdopt", "receiver inline snapshot failed, falling back to sync", e)293            }294        }295        return adopted296    } finally {297        noise.close()298    }299}300301/**302 * Phone-initiator source session for the "empty browser" invitation path.303 * The phone dials the relay room and waits for the empty responder to send304 * ADOPT_REQUEST, then hands over its root key through [grantAdoption]. The305 * SAS gate is the single commit signal — same model as the existing source306 * session — and onConsent is pre-cleared because the user already opted in307 * by scanning the QR.308 */309internal suspend fun runRelayAdoptionInitiatorSourceSession(310    socket: RelayBinarySocket,311    invitation: PairingInvitation,312    sourceDeps: AdoptionSourceDeps,313    confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,314): PairedDevice? {315    socket.send(316        JSONObject()317            .put("type", "PEACH_HELLO")318            .put("token", invitation.token)319            .toString()320            .toByteArray(Charsets.UTF_8),321    )322323    val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))324    require(ack.optString("type") == "PEACH_HELLO_ACK") {325        "expected PEACH_HELLO_ACK"326    }327    // PB-26-103: authenticate the cleartext ACK against the static pinned in the328    // invitation before running Noise. This is the root-key-bearing direction —329    // the phone grants its vault here — so trusting the ACK lets a relay330    // terminate both sessions, present its own key to each side, and collect331    // ADOPT_GRANT.root_key_hex. The mirror check on the receiver path is at332    // runRelayAdoptionReceiverSession.333    val responderStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))334    val invitedStatic = invitation.noiseStaticPublicKeyHex335        ?.let(::hexToPubkey)336        ?: throw IllegalArgumentException("pairing_invitation_missing_static_key")337    val staticMatches = MessageDigest.isEqual(responderStatic, invitedStatic)338    invitedStatic.fill(0)339    if (!staticMatches) {340        responderStatic.fill(0)341        throw IllegalArgumentException("pairing_static_key_mismatch")342    }343    val noise = try {344        noiseNKInitiator(345            responderStatic,346            socket::send,347            socket::receive,348        )349    } finally {350        responderStatic.fill(0)351    }352    try {353        val channel = object : PairingTransport {354            override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))355            override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())356            override suspend fun close() = socket.close()357        }358        val request = receiveFrame(channel)359        if (!isAdoptRequest(request)) return null360        request as AdoptionFrame.Request361        if (!confirm(SASDerivation.derive(noise.handshakeHash), request)) return null362363        val pairState = AdoptionPairState(364            rendezvousSecretHex = Rendezvous.bytesToHex(365                Rendezvous.deriveSecret(noise.handshakeHash),366            ),367            relayUrl = invitation.relayUrl,368        )369        val boundDeps = object : AdoptionSourceDeps {370            override val sourceIdentity = sourceDeps.sourceIdentity371            override val pairState = pairState372            override suspend fun rootKeyHex(): String = sourceDeps.rootKeyHex()373            override suspend fun vaultId(): String = sourceDeps.vaultId()374            override suspend fun license(): String? = sourceDeps.license()375            override suspend fun applyLicense(license: String) {376                sourceDeps.applyLicense(license)377            }378            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true379            override suspend fun upsertPairedDevice(device: PairedDevice) {380                sourceDeps.upsertPairedDevice(device)381            }382            override suspend fun vaultSnapshot() = sourceDeps.vaultSnapshot()383            override suspend fun outgoingDeviceManifest() = sourceDeps.outgoingDeviceManifest()384            override suspend fun markSynced(deviceId: String) = sourceDeps.markSynced(deviceId)385            override suspend fun duressSlotJson() = sourceDeps.duressSlotJson()386        }387        val adopted = grantAdoption(channel, request, boundDeps) ?: return null388        // If the receiver advertised snapshot support, send the vault inline.389        if (request.snapshotCapable == true) {390            try {391                val snapshot = boundDeps.vaultSnapshot()392                if (snapshot != null) {393                    val duressSlot = runCatching { boundDeps.duressSlotJson() }.getOrNull()394                    sendVaultSnapshot(channel, snapshot.first, snapshot.second, duressSlot)395                    val applied = receiveFrame(channel)396                    if (applied is AdoptionFrame.SnapshotApplied) {397                        val manifest = boundDeps.outgoingDeviceManifest()398                        if (manifest != null) {399                            sendFrame(channel, AdoptionFrame.DeviceManifestFrame(manifest))400                            withTimeoutOrNull(5_000) { receiveFrame(channel) }401                        }402                        boundDeps.markSynced(adopted.deviceId)403                    }404                }405            } catch (error: Exception) {406                if (error is SnapshotDuressFrameTooLargeException) throw error407                if (error is CancellationException) throw error408                Log.w("PeachAdopt", "source inline snapshot send failed, receiver falls back to sync", error)409            }410        }411        return adopted412    } finally {413        noise.close()414    }415}