diff --git a/CHANGELOG.md b/CHANGELOG.md index a3e63e1..0db102f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +- Add an Android selected-device connection coordinator joining live + canonical NSD resolution to one explicit pinned-TLS/RVD1 attempt on a + product-supplied executor. Reject closed pairing windows, stale candidates + and duplicate starts; cancel outstanding resolution and active transports + without a delayed dial or automatic pairing fallback. Deterministic + JVM tests cover selection, successful authenticated handoff, cancellation + before queued work, and stale native callbacks. Product secure storage, + native UI and physical-device evidence remain separate. + + - Add the Apple post-RVD1 RVT1 device path: generated Swift APIs can target a shared RivetRPCClient protocol; a bounded network client correlates RPCs, forwards cancellation and delivers events, and its lifecycle-aware run() diff --git a/docs/desktop-closeout-mobile-plan.md b/docs/desktop-closeout-mobile-plan.md index 1d8ab9c..0355852 100644 --- a/docs/desktop-closeout-mobile-plan.md +++ b/docs/desktop-closeout-mobile-plan.md @@ -316,6 +316,10 @@ Kotlin CI job; canonical TXT mapping has JVM tests. The product still owns local-network permission prompts, foreground/multicast lifecycle, secure credential storage and mobile UI; physical-device network pairing remains unverified. +The portable `SelectedDeviceCompanionCoordinator` now bridges that live NSD +selection to exactly one authenticated RVD1 attempt on an injected worker, +with fail-closed cancellation and explicit pairing-window policy. It still +requires product-owned credential storage, UI approval and real-device proof. ### M3 — second desktop host and Android client diff --git a/platform/android/src/main/kotlin/dev/rivet/session/DeviceCompanionSession.kt b/platform/android/src/main/kotlin/dev/rivet/session/DeviceCompanionSession.kt index 21cf2c2..0a187b2 100644 --- a/platform/android/src/main/kotlin/dev/rivet/session/DeviceCompanionSession.kt +++ b/platform/android/src/main/kotlin/dev/rivet/session/DeviceCompanionSession.kt @@ -50,7 +50,7 @@ fun interface DeviceCompanionDialer { ): DeviceCompanionTransport } -private val systemDeviceCompanionDialer = DeviceCompanionDialer { endpoint, authentication -> +internal val systemDeviceCompanionDialer = DeviceCompanionDialer { endpoint, authentication -> val connection = DeviceTLSClient.connect( endpoint.host, endpoint.port, authentication, ) diff --git a/platform/android/src/main/kotlin/dev/rivet/session/Discovery.kt b/platform/android/src/main/kotlin/dev/rivet/session/Discovery.kt index 601ad19..05d33ed 100644 --- a/platform/android/src/main/kotlin/dev/rivet/session/Discovery.kt +++ b/platform/android/src/main/kotlin/dev/rivet/session/Discovery.kt @@ -117,6 +117,14 @@ data class DeviceDiscovery( val pairingWindowOpen: Boolean, ) +/** + * Stable selection identity across snapshots: the pairing-window bit flips + * independently of device presence, so presence checks compare service name + * and listener identity only and judge the window from the fresh result. + */ +fun DeviceDiscovery.namesSelectedDevice(selected: DeviceDiscovery): Boolean = + serviceName == selected.serviceName && advertisementID == selected.advertisementID + enum class DeviceDiscoveryEventKind { ADDED, REMOVED } /** diff --git a/platform/android/src/main/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinator.kt b/platform/android/src/main/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinator.kt new file mode 100644 index 0000000..7c1c102 --- /dev/null +++ b/platform/android/src/main/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinator.kt @@ -0,0 +1,161 @@ +package dev.rivet.session + +import java.io.Closeable +import java.io.InputStream +import java.io.OutputStream +import java.util.concurrent.Executor + +/** A selection or lifecycle rejection, never an alternative trust path. */ +enum class DeviceSelectionFailure { + STALE_SELECTION, + PAIRING_WINDOW_CLOSED, + CANCELLED, +} + +class DeviceSelectionException(val failure: DeviceSelectionFailure) : + IllegalStateException("Rivet selected device connection rejected: $failure") + +/** + * Turns one user-selected live NSD candidate into one TLS/RVD1 connection. + * + * Resolution may finish on an Android NSD callback thread. Actual TLS and + * blocking RVD1 I/O run only on the product-supplied [worker] executor. + * An authenticated stream reaches [onAccepted] only via the underlying + * DeviceCompanionSession's persistence and confirmation gates. + * + * Own this coordinator through the connection lifecycle and close it on + * navigation away, discovery shutdown, or disconnect. The caller must not + * treat the advertisement ID as a pin or auto-retry failed reconnects. + */ +class SelectedDeviceCompanionCoordinator( + private val browser: DeviceDiscoveryBrowser, + resolutionDriver: DeviceServiceResolutionDriver, + private val worker: Executor, + private val credentialStore: SessionCredentialStore? = null, + private val onPairingCode: ((DevicePairingCode) -> Unit)? = null, + private val onAccepted: (SessionAcceptance, InputStream, OutputStream) -> Unit, + private val dialer: DeviceCompanionDialer = systemDeviceCompanionDialer, +) : Closeable { + private val resolver = SelectedDeviceResolver(browser, resolutionDriver) + private val lock = Any() + private var started = false + private var closed = false + private var finished = false + private var current: DeviceCompanionSession? = null + private var callback: ((Result) -> Unit)? = null + + /** Single-flight: a new user selection requires a new coordinator. */ + fun connectSelected( + selection: DeviceDiscovery, + intent: DeviceCompanionIntent, + onFinished: (Result) -> Unit, + ) { + synchronized(lock) { + check(!started && !closed) { "Rivet companion selection is single-use" } + started = true + callback = onFinished + } + if (browser.snapshot.none { it.namesSelectedDevice(selection) }) { + complete(Result.failure(DeviceSelectionException(DeviceSelectionFailure.STALE_SELECTION))) + return + } + if (intent is DeviceCompanionIntent.Pair && !selection.pairingWindowOpen) { + complete(Result.failure(DeviceSelectionException(DeviceSelectionFailure.PAIRING_WINDOW_CLOSED))) + return + } + resolver.resolveSelected(selection) { endpoint -> + if (!isPending()) return@resolveSelected + if (endpoint == null) { + complete(Result.failure(DeviceSelectionException(DeviceSelectionFailure.STALE_SELECTION))) + return@resolveSelected + } + try { + worker.execute { + if (!isPending()) return@execute + // Discovery may change between the native resolution + // callback and this worker actually starting the dial. + // The pairing-window bit flips independently of device + // presence, so staleness matches stable identity and the + // window is judged from the fresh advertisement. + val fresh = browser.snapshot.firstOrNull { it.namesSelectedDevice(selection) } + if (fresh == null) { + complete(Result.failure(DeviceSelectionException(DeviceSelectionFailure.STALE_SELECTION))) + return@execute + } + if (intent is DeviceCompanionIntent.Pair && !fresh.pairingWindowOpen) { + complete(Result.failure(DeviceSelectionException(DeviceSelectionFailure.PAIRING_WINDOW_CLOSED))) + return@execute + } + val session = try { + DeviceCompanionSession( + endpoint = DeviceCompanionEndpoint(endpoint.host, endpoint.port), + intent = intent, + credentialStore = credentialStore, + onPairingCode = onPairingCode, + onAccepted = onAccepted, + dialer = dialer, + ) + } catch (failure: Throwable) { + // A rejected endpoint or session configuration must + // still resolve the caller's callback instead of + // killing the shared worker thread. + complete(Result.failure(failure)) + return@execute + } + val abandoned = synchronized(lock) { + if (closed || finished) true else { + current = session + false + } + } + if (abandoned) { + session.close() + return@execute + } + try { + val accepted = session.run() + complete(Result.success(accepted)) + } catch (failure: Throwable) { + session.close() + complete(Result.failure(failure)) + } + } + } catch (failure: RuntimeException) { + complete(Result.failure(failure)) + } + } + } + + /** A separate user decision, never inferred from the discovery result. */ + fun confirmPairingOnDevice(confirmed: Boolean) { + val session = synchronized(lock) { current } + ?: throw IllegalStateException("Rivet selected pairing has not started") + session.confirmPairingOnDevice(confirmed) + } + + private fun isPending(): Boolean = synchronized(lock) { started && !closed && !finished } + + private fun complete(result: Result) { + val notify = synchronized(lock) { + if (finished) null else { + finished = true + callback.also { callback = null } + } + } + notify?.invoke(result) + } + + /** Idempotent and effective while resolving, queued, dialing, or connected. */ + override fun close() { + val session: DeviceCompanionSession? + synchronized(lock) { + if (closed) return + closed = true + session = current + current = null + } + resolver.cancel() + session?.close() + complete(Result.failure(DeviceSelectionException(DeviceSelectionFailure.CANCELLED))) + } +} diff --git a/platform/android/src/test/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinatorTest.kt b/platform/android/src/test/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinatorTest.kt new file mode 100644 index 0000000..55a0ab7 --- /dev/null +++ b/platform/android/src/test/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinatorTest.kt @@ -0,0 +1,297 @@ +package dev.rivet.session + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.io.OutputStream +import java.util.concurrent.Executor +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class SelectedDeviceCompanionCoordinatorTest { + private val advertisementID = "0f1e2d3c4b5a69788796a5b4c3d2e1f0" + private val id = SessionUuid(ByteArray(16) { it.toByte() }) + private val nonce = ByteArray(SESSION_NONCE_SIZE) { 0x22 } + private val pin = ByteArray(SESSION_FINGERPRINT_SIZE) { 0x55 } + + private class Harness { + lateinit var discovery: (String, ByteArray?, Boolean) -> Unit + var resolved: ((DeviceResolvedService?) -> Unit)? = null + var resolutionCount = 0 + val browser = DeviceDiscoveryBrowser(object : DeviceDiscoveryDriver { + override fun start(onResult: (String, ByteArray?, Boolean) -> Unit) { discovery = onResult } + override fun stop() {} + }) { _, _ -> } + + val driver = DeviceServiceResolutionDriver { _, result -> + resolutionCount++ + resolved = result + } + + fun select(advertisementID: String, pairing: Boolean): DeviceDiscovery { + browser.start() + discovery("Device A", encodeDiscoveryTXT(DeviceAdvertisement(advertisementID, pairing)), false) + return browser.snapshot.single() + } + + fun reply(selected: DeviceDiscovery) { + resolved!!( + DeviceResolvedService( + selected.serviceName, "192.0.2.1", 12345, + encodeDiscoveryTXT(DeviceAdvertisement(selected.advertisementID, selected.pairingWindowOpen)), + ), + ) + } + } + + private fun intent(): DeviceCompanionIntent = DeviceCompanionIntent.Reconnect( + "com.example.app", id, ByteArray(SESSION_CREDENTIAL_SIZE) { 0x33 }, pin, + ) + + @Test + fun selectedReconnectHandsOffOnlyAfterAuthenticationAndNoDuplicateDial() { + val h = Harness() + val selected = h.select(advertisementID, false) + val sessionID = SessionUuid(ByteArray(16) { 0x44 }) + val incoming = ByteArrayInputStream( + encodeSessionRecord(SessionMessage.AuthChallenge(nonce)) + + encodeSessionRecord(SessionMessage.Accept(sessionID, listOf("reader"), 512L * 1024, 64)), + ) + val outgoing = ByteArrayOutputStream() + var dials = 0 + var handed = 0 + val decisions = mutableListOf>() + val coordinator = SelectedDeviceCompanionCoordinator( + h.browser, h.driver, Executor { it.run() }, + onAccepted = { acceptance, input, output -> + handed++ + assertEquals(sessionID, acceptance.sessionID) + assertSame(incoming, input) + assertSame(outgoing, output) + }, + dialer = DeviceCompanionDialer { _, authentication -> + dials++ + assertTrue(authentication is DeviceServerAuthentication.Pinned) + object : DeviceCompanionTransport { + override val serverFingerprint get() = pin.copyOf() + override val input: InputStream get() = incoming + override val output: OutputStream get() = outgoing + override fun close() { incoming.close(); outgoing.close() } + } + }, + ) + coordinator.connectSelected(selected, intent()) { decisions += it } + h.reply(selected) + h.reply(selected) + assertEquals(1, dials) + assertEquals(1, handed) + assertEquals(1, decisions.size) + assertEquals(sessionID, decisions.single().getOrThrow().sessionID) + assertFailsWith { + coordinator.connectSelected(selected, intent()) { } + } + coordinator.close() + assertEquals(1, decisions.size) + h.browser.stop() + } + + @Test + fun pairingRequiresAdvertisedOpenWindowAndCannotDialSilently() { + val h = Harness() + val selected = h.select(advertisementID, false) + var dialed = false + val results = mutableListOf>() + val coordinator = SelectedDeviceCompanionCoordinator( + h.browser, h.driver, Executor { it.run() }, + onAccepted = { _, _, _ -> }, + dialer = DeviceCompanionDialer { _, _ -> + dialed = true + error("must not connect") + }, + ) + coordinator.connectSelected( + selected, + DeviceCompanionIntent.Pair("com.example.app", id, "Android"), + ) { results += it } + assertEquals(0, h.resolutionCount) + val failure = results.single().exceptionOrNull() as DeviceSelectionException + assertEquals(DeviceSelectionFailure.PAIRING_WINDOW_CLOSED, failure.failure) + assertFalse(dialed) + coordinator.close() + } + + @Test + fun staleResolutionCannotDialAndCallbacksRemainSingleShot() { + val h = Harness() + val selected = h.select(advertisementID, true) + var dialed = false + val results = mutableListOf>() + val coordinator = SelectedDeviceCompanionCoordinator( + h.browser, h.driver, Executor { it.run() }, + onAccepted = { _, _, _ -> }, + dialer = DeviceCompanionDialer { _, _ -> + dialed = true + error("must not connect") + }, + ) + coordinator.connectSelected(selected, intent()) { results += it } + h.discovery(selected.serviceName, null, true) + h.reply(selected) + h.reply(selected) + assertEquals(1, results.size) + assertEquals( + DeviceSelectionFailure.STALE_SELECTION, + (results.single().exceptionOrNull() as DeviceSelectionException).failure, + ) + assertFalse(dialed) + coordinator.close() + } + + @Test + fun queuedDialCannotRunAfterCloseAndCancellationOnlyReportsOnce() { + val h = Harness() + val selected = h.select(advertisementID, true) + val pending = mutableListOf() + var dialed = false + val results = mutableListOf>() + val coordinator = SelectedDeviceCompanionCoordinator( + h.browser, h.driver, Executor { pending += it }, + onAccepted = { _, _, _ -> }, + dialer = DeviceCompanionDialer { _, _ -> + dialed = true + error("must not connect") + }, + ) + coordinator.connectSelected(selected, intent()) { results += it } + h.reply(selected) + assertEquals(1, pending.size) + coordinator.close() + coordinator.close() + pending.single().run() + assertEquals(1, results.size) + assertEquals( + DeviceSelectionFailure.CANCELLED, + (results.single().exceptionOrNull() as DeviceSelectionException).failure, + ) + assertFalse(dialed) + h.browser.stop() + } + + @Test + fun missingSelectionDoesNotResolveOrDial() { + val h = Harness() + val selection = DeviceDiscovery("Not listed", advertisementID, true) + val results = mutableListOf>() + val coordinator = SelectedDeviceCompanionCoordinator( + h.browser, h.driver, Executor { it.run() }, + onAccepted = { _, _, _ -> }, + ) + coordinator.connectSelected(selection, intent()) { results += it } + assertEquals(0, h.resolutionCount) + assertEquals( + DeviceSelectionFailure.STALE_SELECTION, + (results.single().exceptionOrNull() as DeviceSelectionException).failure, + ) + coordinator.close() + } + + @Test + fun rejectedResolutionFailsCallbackOnceWithoutDialingOrQueueingWork() { + val h = Harness() + val selected = h.select(advertisementID, true) + val pending = mutableListOf() + var dialed = false + val results = mutableListOf>() + val coordinator = SelectedDeviceCompanionCoordinator( + h.browser, h.driver, Executor { pending += it }, + onAccepted = { _, _, _ -> }, + dialer = DeviceCompanionDialer { _, _ -> + dialed = true + error("must not connect") + }, + ) + coordinator.connectSelected(selected, intent()) { results += it } + // An out-of-range port fails the resolver's validation, so the + // selection resolves to null and must complete the caller's callback + // exactly once without queueing worker work or opening a dial. + h.resolved!!( + DeviceResolvedService( + selected.serviceName, "192.0.2.1", 0, + encodeDiscoveryTXT(DeviceAdvertisement(selected.advertisementID, selected.pairingWindowOpen)), + ), + ) + assertEquals(0, pending.size) + assertEquals(1, results.size) + assertEquals( + DeviceSelectionFailure.STALE_SELECTION, + (results.single().exceptionOrNull() as DeviceSelectionException).failure, + ) + assertFalse(dialed) + coordinator.close() + } + + @Test + fun windowFlipBeforeWorkerStartJudgedFromFreshAdvertisement() { + val h = Harness() + val selected = h.select(advertisementID, true) + val pending = mutableListOf() + var dialed = false + val results = mutableListOf>() + val coordinator = SelectedDeviceCompanionCoordinator( + h.browser, h.driver, Executor { pending += it }, + onAccepted = { _, _, _ -> }, + dialer = DeviceCompanionDialer { _, _ -> + dialed = true + error("stop at dial") + }, + ) + coordinator.connectSelected(selected, intent()) { results += it } + h.reply(selected) + // Only the pairing-window bit flips; the device is still present, so a + // reconnect dial proceeds while a pair dial fails with the window code. + h.discovery( + selected.serviceName, + encodeDiscoveryTXT(DeviceAdvertisement(selected.advertisementID, false)), + false, + ) + pending.single().run() + assertTrue(dialed) + coordinator.close() + + val h2 = Harness() + val selected2 = h2.select(advertisementID, true) + val pending2 = mutableListOf() + var dialed2 = false + val results2 = mutableListOf>() + val coordinator2 = SelectedDeviceCompanionCoordinator( + h2.browser, h2.driver, Executor { pending2 += it }, + onAccepted = { _, _, _ -> }, + dialer = DeviceCompanionDialer { _, _ -> + dialed2 = true + error("must not connect") + }, + ) + coordinator2.connectSelected( + selected2, + DeviceCompanionIntent.Pair("com.example.app", id, "Android"), + ) { results2 += it } + h2.reply(selected2) + h2.discovery( + selected2.serviceName, + encodeDiscoveryTXT(DeviceAdvertisement(selected2.advertisementID, false)), + false, + ) + pending2.single().run() + assertEquals(1, results2.size) + assertEquals( + DeviceSelectionFailure.PAIRING_WINDOW_CLOSED, + (results2.single().exceptionOrNull() as DeviceSelectionException).failure, + ) + assertFalse(dialed2) + coordinator2.close() + } +}