VaultAdoptTest.kt

30.4 KB · raw

1package com.peachpasswords.sync23import kotlinx.coroutines.async4import kotlinx.coroutines.channels.Channel5import kotlinx.coroutines.runBlocking6import kotlinx.coroutines.withTimeoutOrNull7import org.junit.Assert.assertEquals8import org.junit.Assert.assertFalse9import org.junit.Assert.assertNull10import org.junit.Assert.assertTrue11import org.junit.Test1213/**14 * Adoption frame protocol parity with the browser `vault-adopt.test.ts`. The15 * receiver must verify the granted root key derives the advertised vault id16 * BEFORE sealing a vault, and a denied consent must hand over nothing.17 */18class VaultAdoptTest {1920    private fun memoryPair(): Pair<PairingTransport, PairingTransport> {21        val a2b = Channel<ByteArray>(Channel.UNLIMITED)22        val b2a = Channel<ByteArray>(Channel.UNLIMITED)23        val a = object : PairingTransport {24            override suspend fun send(msg: ByteArray) { a2b.send(msg) }25            override suspend fun receive(): ByteArray = b2a.receive()26            override suspend fun close() {}27        }28        val b = object : PairingTransport {29            override suspend fun send(msg: ByteArray) { b2a.send(msg) }30            override suspend fun receive(): ByteArray = a2b.receive()31            override suspend fun close() {}32        }33        return a to b34    }3536    private fun identity(name: String) = LocalDeviceIdentity(37        deviceId = "$name-id",38        deviceName = name,39        deviceType = DeviceType.EXTENSION,40        longTermPublicKeyHex = "ab".repeat(32),41        signingPublicKeyHex = "cd".repeat(32),42    )4344    private fun vaultIdFor(rootKeyHex: String): String {45        val bytes = hexToPubkey(rootKeyHex)46        return try {47            computeVaultId(bytes)48        } finally {49            bytes.fill(0)50        }51    }5253    private open class RecorderReceiver(54        override val deviceName: String,55        override val deviceType: DeviceType,56        private val vaultId: String?,57        private val createThrows: Boolean = false,58        private val derived: LocalDeviceIdentity? = null,59        private val sasConfirmed: Boolean = true,60        private val localLicense: String? = "PPK-test",61    ) : AdoptionReceiverDeps {62        var createCalledWith: String? = null63        var licenseApplied: String? = null64        val revocationsCleared = mutableListOf<String>()65        val upserted = mutableListOf<PairedDevice>()66        val errors = mutableListOf<String>()67        var collidingSourceDeviceId: String? = null68        override fun generateDeviceId(): String = "receiver-ephemeral"69        override suspend fun confirmSas(): Boolean = sasConfirmed70        override suspend fun createVaultFromRootKey(rootKeyHex: String): String {71            createCalledWith = rootKeyHex72            if (createThrows) throw IllegalStateException("commit_failed")73            return vaultId ?: error("no vault id")74        }75        override suspend fun deriveLocalIdentity(): LocalDeviceIdentity = derived ?: error("no identity")76        override suspend fun hasSourceIdentityCollision(source: AdoptionFrame.Source): Boolean =77            source.deviceId == collidingSourceDeviceId78        override suspend fun clearPairedDeviceRevocation(deviceId: String) {79            revocationsCleared += deviceId80        }81        override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }82        override suspend fun applyLicense(license: String) { licenseApplied = license }83        override suspend fun license(): String? = localLicense84        override fun onError(error: String) { errors += error }85    }8687    private class RecorderSource(88        override val sourceIdentity: LocalDeviceIdentity,89        private val rootKeyHex: String,90        private val vaultId: String,91        license: String? = "PPK-test",92        private val consent: Boolean = true,93    ) : AdoptionSourceDeps {94        private var storedLicense: String? = license95        val upserted = mutableListOf<PairedDevice>()96        var licenseApplied: String? = null97        var consentAsked = false98        var rootRequested = false99        var collidingDeviceId: String? = null100        var rotatedSourceIdentity: LocalDeviceIdentity? = null101        override suspend fun rootKeyHex(): String {102            rootRequested = true103            return rootKeyHex104        }105        override suspend fun vaultId(): String = vaultId106        override suspend fun license(): String? = storedLicense107        override suspend fun applyLicense(license: String) {108            licenseApplied = license109            storedLicense = license110        }111        override suspend fun onConsent(request: AdoptionFrame.Request): Boolean {112            consentAsked = true113            return consent114        }115        override fun hasDeviceIdentityCollision(device: PairedDevice): Boolean =116            device.deviceId == collidingDeviceId117        override suspend fun rotateSourceIdentity(): LocalDeviceIdentity =118            rotatedSourceIdentity ?: error("no rotated source identity")119        override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }120    }121122    @Test fun completes_request_through_mutual_completion_acknowledgements() = runBlocking {123        val (receiverCh, sourceCh) = memoryPair()124        val rootKeyHex = "11".repeat(32)125        val vaultId = vaultIdFor(rootKeyHex)126        val receiverIdentity = identity("receiver").copy(deviceType = DeviceType.ANDROID)127        val receiver = RecorderReceiver(128            "Receiver",129            DeviceType.ANDROID,130            vaultId,131            derived = receiverIdentity,132            localLicense = null,133        )134        val source = RecorderSource(identity("source"), rootKeyHex, vaultId, license = "PPK-test")135136        val recvJob = async { requestAdoption(receiverCh, receiver) { 111L } }137        val grantJob = async {138            val request = receiveFrame(sourceCh) as AdoptionFrame.Request139            grantAdoption(sourceCh, request, source) { 222L }140        }141        recvJob.await()142        grantJob.await()143144        assertEquals(rootKeyHex, receiver.createCalledWith)145        assertEquals("PPK-test", receiver.licenseApplied)146        assertEquals(listOf("source-id"), receiver.revocationsCleared)147        assertEquals("source-id", receiver.upserted.single().deviceId)148        assertEquals("receiver-id", source.upserted.single().deviceId)149        assertEquals("android", source.upserted.single().deviceType)150    }151152    @Test fun source_preserves_existing_peer_and_accepts_rotated_identity_after_collision() = runBlocking {153        val (receiverCh, sourceCh) = memoryPair()154        val rootKeyHex = "13".repeat(32)155        val vaultId = vaultIdFor(rootKeyHex)156        val source = RecorderSource(identity("android"), rootKeyHex, vaultId).apply {157            collidingDeviceId = "chrome-id"158        }159160        val sourcePromise = async {161            grantAdoption(sourceCh, AdoptionFrame.Request(162                sessionRequestId = "receiver-ephemeral",163                deviceName = "Firefox",164                deviceType = DeviceType.EXTENSION.wireValue,165            ), source)166        }167        sendFrame(receiverCh, AdoptionFrame.Confirm(true))168        assertTrue(receiveFrame(receiverCh) is AdoptionFrame.Grant)169        sendFrame(receiverCh, AdoptionFrame.Identity(170            deviceId = "chrome-id",171            deviceName = "Firefox",172            deviceType = DeviceType.EXTENSION.wireValue,173            longTermPublicKeyHex = "11".repeat(32),174        ))175        val rejection = receiveFrame(receiverCh)176        assertTrue(rejection is AdoptionFrame.Reject)177        assertEquals("ADOPT_DEVICE_ID_COLLISION", (rejection as AdoptionFrame.Reject).reason)178        source.collidingDeviceId = null179        sendFrame(receiverCh, AdoptionFrame.Identity(180            deviceId = "firefox-id",181            deviceName = "Firefox",182            deviceType = DeviceType.EXTENSION.wireValue,183            longTermPublicKeyHex = "22".repeat(32),184        ))185        assertTrue(receiveFrame(receiverCh) is AdoptionFrame.Complete)186        sendFrame(receiverCh, AdoptionFrame.Applied)187188        assertEquals("firefox-id", sourcePromise.await()?.deviceId)189        assertEquals(listOf("firefox-id"), source.upserted.map { it.deviceId })190    }191192    @Test fun receiver_rejects_revoked_source_identity_and_accepts_one_rotation() = runBlocking {193        val (receiverCh, sourceCh) = memoryPair()194        val rootKeyHex = "14".repeat(32)195        val vaultId = vaultIdFor(rootKeyHex)196        val receiver = RecorderReceiver(197            "Android",198            DeviceType.ANDROID,199            vaultId,200            derived = identity("receiver").copy(deviceType = DeviceType.ANDROID),201        ).apply {202            collidingSourceDeviceId = "chrome-id"203        }204205        val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }206        receiveFrame(sourceCh) // ADOPT_REQUEST207        receiveFrame(sourceCh) // ADOPT_CONFIRM208        sendFrame(sourceCh, AdoptionFrame.Grant(209            rootKeyHex = rootKeyHex,210            vaultId = vaultId,211            source = AdoptionFrame.Source(212                "chrome-id",213                "Chrome",214                "extension",215                "ab".repeat(32),216            ),217        ))218        val rejection = receiveFrame(sourceCh)219        assertEquals("ADOPT_DEVICE_ID_COLLISION", (rejection as AdoptionFrame.Reject).reason)220221        receiver.collidingSourceDeviceId = null222        sendFrame(sourceCh, AdoptionFrame.Grant(223            rootKeyHex = rootKeyHex,224            vaultId = vaultId,225            source = AdoptionFrame.Source(226                "chrome-fresh-id",227                "Chrome",228                "extension",229                "ef".repeat(32),230            ),231        ))232        assertTrue(receiveFrame(sourceCh) is AdoptionFrame.Identity)233        sendFrame(sourceCh, AdoptionFrame.Complete)234        assertTrue(receiveFrame(sourceCh) is AdoptionFrame.Applied)235236        assertEquals("chrome-fresh-id", receiverJob.await()?.deviceId)237        assertEquals(listOf("chrome-fresh-id"), receiver.revocationsCleared)238        assertEquals(listOf("chrome-fresh-id"), receiver.upserted.map { it.deviceId })239    }240241    @Test fun source_rotates_its_revoked_identity_and_resends_grant_once() = runBlocking {242        val (receiverCh, sourceCh) = memoryPair()243        val rootKeyHex = "15".repeat(32)244        val vaultId = vaultIdFor(rootKeyHex)245        val source = RecorderSource(identity("chrome"), rootKeyHex, vaultId).apply {246            rotatedSourceIdentity = identity("chrome-fresh")247        }248249        val sourceJob = async {250            grantAdoption(sourceCh, AdoptionFrame.Request(251                sessionRequestId = "receiver-ephemeral",252                deviceName = "Android",253                deviceType = DeviceType.ANDROID.wireValue,254            ), source)255        }256        sendFrame(receiverCh, AdoptionFrame.Confirm(true))257        val firstGrant = receiveFrame(receiverCh) as AdoptionFrame.Grant258        assertEquals("chrome-id", firstGrant.source.deviceId)259        sendFrame(receiverCh, AdoptionFrame.Reject("ADOPT_DEVICE_ID_COLLISION"))260        val secondGrant = receiveFrame(receiverCh) as AdoptionFrame.Grant261        assertEquals("chrome-fresh-id", secondGrant.source.deviceId)262        sendFrame(receiverCh, AdoptionFrame.Identity(263            deviceId = "android-id",264            deviceName = "Android",265            deviceType = DeviceType.ANDROID.wireValue,266            longTermPublicKeyHex = "22".repeat(32),267        ))268        assertTrue(receiveFrame(receiverCh) is AdoptionFrame.Complete)269        sendFrame(receiverCh, AdoptionFrame.Applied)270271        assertEquals("android-id", sourceJob.await()?.deviceId)272        assertEquals(listOf("android-id"), source.upserted.map { it.deviceId })273    }274275    @Test fun rejects_without_creating_or_pairing_when_neither_peer_has_pro() = runBlocking {276        val (receiverCh, sourceCh) = memoryPair()277        val rootKeyHex = "12".repeat(32)278        val vaultId = vaultIdFor(rootKeyHex)279        val receiver = RecorderReceiver(280            "Free receiver",281            DeviceType.ANDROID,282            vaultId,283            localLicense = null,284        )285        val source = RecorderSource(286            identity("free-source"),287            rootKeyHex,288            vaultId,289            license = null,290        )291292        val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }293        val sourceJob = async {294            val request = receiveFrame(sourceCh) as AdoptionFrame.Request295            runCatching { grantAdoption(sourceCh, request, source) { 0L } }296        }297298        assertNull(receiverJob.await())299        val sourceOutcome = sourceJob.await()300        assertTrue(sourceOutcome.isFailure)301        assertEquals("ADOPT_PRO_REQUIRED", sourceOutcome.exceptionOrNull()?.message)302        assertEquals(listOf("ADOPT_PRO_REQUIRED"), receiver.errors)303        assertNull(receiver.createCalledWith)304        assertTrue(receiver.upserted.isEmpty())305        assertTrue(source.upserted.isEmpty())306    }307308    @Test fun receiver_pro_license_is_transferred_to_a_free_source() = runBlocking {309        val (receiverCh, sourceCh) = memoryPair()310        val rootKeyHex = "13".repeat(32)311        val vaultId = vaultIdFor(rootKeyHex)312        val receiver = RecorderReceiver(313            "Pro receiver",314            DeviceType.ANDROID,315            vaultId,316            derived = identity("receiver").copy(deviceType = DeviceType.ANDROID),317            localLicense = "PPK-receiver",318        )319        val source = RecorderSource(320            identity("free-source"),321            rootKeyHex,322            vaultId,323            license = null,324        )325326        val receiverJob = async { requestAdoption(receiverCh, receiver) { 1L } }327        val sourceJob = async {328            val request = receiveFrame(sourceCh) as AdoptionFrame.Request329            grantAdoption(sourceCh, request, source) { 2L }330        }331332        assertEquals("free-source-id", receiverJob.await()?.deviceId)333        assertEquals("receiver-id", sourceJob.await()?.deviceId)334        assertEquals("PPK-receiver", source.licenseApplied)335    }336337    /**338     * The constraint from `PEACH_RELAY_AND_DEVICEID_FIXES.md` Part 2: the label339     * on ADOPT_REQUEST is generated before any key exists, so persisting it as340     * an identity would key trust on a value with no continuity between341     * attempts. What lands in the store must come from ADOPT_IDENTITY.342     */343    @Test fun request_label_is_never_what_gets_persisted() = runBlocking {344        val (receiverCh, sourceCh) = memoryPair()345        val rootKeyHex = "55".repeat(32)346        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))347        // Deliberately unlike the identity the receiver will present.348        val request = AdoptionFrame.Request("ephemeral-label-not-an-identity", "Receiver", "extension")349350        val grantJob = async { grantAdoption(sourceCh, request, source) { 0L } }351        sendFrame(receiverCh, AdoptionFrame.Confirm(true))352        receiveFrame(receiverCh) // the grant353        sendFrame(354            receiverCh,355            AdoptionFrame.Identity(356                deviceId = "receiver-id",357                deviceName = "Receiver",358                deviceType = "extension",359                longTermPublicKeyHex = "aa".repeat(32),360                signingPublicKeyHex = "bb".repeat(32),361            ),362        )363        receiveFrame(receiverCh) // ADOPT_COMPLETE364        sendFrame(receiverCh, AdoptionFrame.Applied)365        grantJob.await()366367        val persisted = source.upserted.single()368        assertEquals("receiver-id", persisted.deviceId)369        assertEquals("aa".repeat(32), persisted.longTermPublicKeyHex)370        assertTrue(371            "the ephemeral request label must not reach the paired-device store",372            source.upserted.none { it.deviceId == "ephemeral-label-not-an-identity" },373        )374    }375376    @Test fun request_carries_receiver_device_type() = runBlocking {377        val (receiverCh, sourceCh) = memoryPair()378        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)379        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }380        val request = receiveFrame(sourceCh) as AdoptionFrame.Request381        recvJob.cancel()382383        assertEquals("android", request.deviceType)384        assertEquals("receiver-ephemeral", request.sessionRequestId)385        assertEquals("Receiver", request.deviceName)386    }387388    @Test fun denied_consent_grants_nothing() = runBlocking {389        val (receiverCh, sourceCh) = memoryPair()390        val rootKeyHex = "33".repeat(32)391        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultIdFor(rootKeyHex))392        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex), consent = false)393394        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }395        val request = receiveFrame(sourceCh) as AdoptionFrame.Request396        val granted = grantAdoption(sourceCh, request, source) { 0L }397        recvJob.cancel()398399        assertNull(granted)400        assertTrue(source.consentAsked)401        assertFalse(source.rootRequested)402        assertTrue(source.upserted.isEmpty())403        assertNull(receiver.createCalledWith)404    }405406    @Test fun receiver_sas_rejection_grants_no_root_key() = runBlocking {407        val (receiverCh, sourceCh) = memoryPair()408        val rootKeyHex = "66".repeat(32)409        val receiver = RecorderReceiver(410            "Receiver",411            DeviceType.ANDROID,412            vaultIdFor(rootKeyHex),413            sasConfirmed = false,414        )415        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))416417        val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }418        val request = receiveFrame(sourceCh) as AdoptionFrame.Request419        val sourceJob = async { grantAdoption(sourceCh, request, source) { 0L } }420421        assertNull(receiverJob.await())422        assertNull(sourceJob.await())423        assertFalse(source.rootRequested)424        assertNull(receiver.createCalledWith)425        assertTrue(receiver.revocationsCleared.isEmpty())426        assertEquals(listOf("ADOPT_SAS_REJECTED"), receiver.errors)427    }428429    @Test fun invalid_root_key_hex_aborts_as_unexpected_frame() = runBlocking {430        val (receiverCh, sourceCh) = memoryPair()431        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)432        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }433        receiveFrame(sourceCh)434        sendFrame(sourceCh, AdoptionFrame.Grant(435            rootKeyHex = "bad",436            vaultId = "00".repeat(32),437            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),438        ))439        recvJob.await()440441        assertEquals(listOf("ADOPT_UNEXPECTED_FRAME"), receiver.errors)442        assertNull(receiver.createCalledWith)443    }444445    @Test fun vault_id_mismatch_aborts_before_vault_creation() = runBlocking {446        val (receiverCh, sourceCh) = memoryPair()447        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)448        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }449        receiveFrame(sourceCh)450        sendFrame(sourceCh, AdoptionFrame.Grant(451            rootKeyHex = "22".repeat(32),452            vaultId = "00".repeat(32),453            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),454        ))455        recvJob.await()456457        assertEquals(listOf("ADOPT_VAULT_ID_MISMATCH"), receiver.errors)458        assertNull(receiver.createCalledWith)459    }460461    @Test fun source_waits_for_identity_before_persisting_receiver() = runBlocking {462        val (receiverCh, sourceCh) = memoryPair()463        val rootKeyHex = "33".repeat(32)464        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))465        val request = AdoptionFrame.Request("receiver-ephemeral", "Receiver", "extension")466467        sendFrame(receiverCh, AdoptionFrame.Confirm(true))468        val result = withTimeoutOrNull(150) {469            // No receiver answers the ADOPT_IDENTITY read, so grant must hang, not persist.470            grantAdoption(sourceCh, request, source) { 0L }471        }472473        assertNull(result)474        assertTrue(source.upserted.isEmpty())475    }476477    @Test fun receiver_does_not_persist_source_without_complete() = runBlocking {478        val (receiverCh, sourceCh) = memoryPair()479        val rootKeyHex = "77".repeat(32)480        val vaultId = vaultIdFor(rootKeyHex)481        val receiver = RecorderReceiver(482            "Receiver",483            DeviceType.ANDROID,484            vaultId,485            derived = identity("receiver").copy(deviceType = DeviceType.ANDROID),486        )487        val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }488489        receiveFrame(sourceCh) // ADOPT_REQUEST490        receiveFrame(sourceCh) // ADOPT_CONFIRM491        sendFrame(492            sourceCh,493            AdoptionFrame.Grant(494                rootKeyHex = rootKeyHex,495                vaultId = vaultId,496                source = AdoptionFrame.Source(497                    "source-id",498                    "Source",499                    "extension",500                    "ab".repeat(32),501                ),502            ),503        )504        receiveFrame(sourceCh) // ADOPT_IDENTITY505        sendFrame(sourceCh, AdoptionFrame.Confirm(true))506507        assertNull(receiverJob.await())508        assertEquals(listOf("ADOPT_COMPLETION_MISSING"), receiver.errors)509        assertTrue(receiver.revocationsCleared.isEmpty())510        assertTrue(receiver.upserted.isEmpty())511    }512513    @Test fun receiver_safety_policy_runs_before_vault_creation() = runBlocking {514        val (receiverCh, sourceCh) = memoryPair()515        val rootKeyHex = "78".repeat(32)516        val vaultId = vaultIdFor(rootKeyHex)517        val receiver = object : RecorderReceiver(518            "Receiver",519            DeviceType.ANDROID,520            vaultId,521            derived = identity("receiver").copy(deviceType = DeviceType.ANDROID),522        ) {523            override suspend fun validateSource(source: AdoptionFrame.Source) {524                throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")525            }526        }527        val receiverJob = async {528            runCatching { requestAdoption(receiverCh, receiver) { 0L } }529        }530531        receiveFrame(sourceCh) // ADOPT_REQUEST532        receiveFrame(sourceCh) // ADOPT_CONFIRM533        sendFrame(534            sourceCh,535            AdoptionFrame.Grant(536                rootKeyHex = rootKeyHex,537                vaultId = vaultId,538                source = AdoptionFrame.Source(539                    "new-source",540                    "Source",541                    "extension",542                    "ab".repeat(32),543                ),544            ),545        )546547        val outcome = receiverJob.await()548        assertTrue(outcome.isFailure)549        assertEquals("JOIN_POPULATED_VAULT_BLOCKED", outcome.exceptionOrNull()?.message)550        assertNull(receiver.createCalledWith)551    }552553    @Test fun applies_valid_license_before_vault_creation_failure() = runBlocking {554        val (receiverCh, sourceCh) = memoryPair()555        val rootKeyHex = "44".repeat(32)556        val vaultId = vaultIdFor(rootKeyHex)557        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, createThrows = true, localLicense = null)558        val recvJob = async {559            runCatching { requestAdoption(receiverCh, receiver) { 0L } }560        }561        receiveFrame(sourceCh)562        sendFrame(sourceCh, AdoptionFrame.Grant(563            rootKeyHex = rootKeyHex,564            vaultId = vaultId,565            license = "PPK-test",566            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),567        ))568        val outcome = recvJob.await()569570        assertTrue(outcome.isFailure)571        assertEquals("PPK-test", receiver.licenseApplied)572        assertEquals(rootKeyHex, receiver.createCalledWith)573    }574575    @Test fun invalid_license_fails_before_vault_creation() = runBlocking {576        val (receiverCh, sourceCh) = memoryPair()577        val rootKeyHex = "45".repeat(32)578        val vaultId = vaultIdFor(rootKeyHex)579        val receiver = object : RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, localLicense = null) {580            override suspend fun applyLicense(license: String) {581                throw IllegalStateException("license_transfer_failed")582            }583        }584        val receiverJob = async { runCatching { requestAdoption(receiverCh, receiver) { 0L } } }585        receiveFrame(sourceCh)586        sendFrame(sourceCh, AdoptionFrame.Grant(587            rootKeyHex = rootKeyHex,588            vaultId = vaultId,589            license = "PPK-invalid",590            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),591        ))592593        val outcome = receiverJob.await()594        assertTrue(outcome.isFailure)595        assertEquals("license_transfer_failed", outcome.exceptionOrNull()?.message)596        assertNull(receiver.createCalledWith)597        assertTrue(receiver.upserted.isEmpty())598    }599600    @Test fun validator_rejects_short_and_uppercase_root_keys() {601        assertTrue(validateRootKeyHex("ab".repeat(32)))602        assertFalse(validateRootKeyHex("ab".repeat(31)))603        assertFalse(validateRootKeyHex("AB".repeat(32)))604        assertFalse(validateRootKeyHex("zz".repeat(32)))605        assertFalse(validateRootKeyHex(null))606    }607608    @Test fun grant_omits_absent_optionals_like_json_stringify() {609        val grant = AdoptionFrame.Grant(610            rootKeyHex = "11".repeat(32),611            vaultId = "22".repeat(32),612            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),613        )614        val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), grant)615616        assertFalse(json.contains("license"))617        assertFalse(json.contains("ws_port"))618        assertFalse(json.contains("signing_public_key_hex"))619        assertTrue(json.contains("\"type\":\"ADOPT_GRANT\""))620        assertTrue(json.contains("\"root_key_hex\""))621    }622623    @Test fun grant_json_carries_snapshot_capable_true_when_request_advertised_it() {624        val grant = AdoptionFrame.Grant(625            rootKeyHex = "11".repeat(32),626            vaultId = "22".repeat(32),627            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),628            snapshotCapable = true,629        )630        val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), grant)631        assertTrue(632            "explicit snapshot_capable: true must serialize",633            json.contains("\"snapshot_capable\":true"),634        )635    }636637    @Test fun grant_decodes_snapshot_capable_false_when_field_absent() {638        // Android wire shape diverges from browser here: AdoptJson uses639        // encodeDefaults = false, so a Grant constructed with snapshotCapable = false640        // (the Kotlin default) serializes WITHOUT the field. The decoder then641        // defaults it back to false. This is wire-compatible with both ends:642        // pre-C sources that never set the field decode to false (receiver skips643        // 15s wait), and v4.6.1 browser sources that DO set it to false... actually644        // browser always sends explicit true OR false; Android only sends true.645        // Both shapes decode correctly on the receiver side because the receiver's646        // gate is `if (sourceCanSendSnapshot)` — missing field, false field, and647        // undefined all flow to "skip wait". Documented as a deliberate divergence.648        val withoutField = AdoptJson.decodeFromString(649            AdoptionFrame.serializer(),650            "{\"type\":\"ADOPT_GRANT\",\"root_key_hex\":\"${"11".repeat(32)}\",\"vault_id\":\"${"22".repeat(32)}\",\"source\":{\"device_id\":\"source\",\"device_name\":\"Source\",\"device_type\":\"extension\",\"long_term_public_key_hex\":\"${"ab".repeat(32)}\"}}",651        ) as AdoptionFrame.Grant652        assertFalse(653            "missing field must decode to default false (receiver skips 15s wait)",654            withoutField.snapshotCapable,655        )656        // And explicit-false round-trips (when a future change makes Android send657        // explicit false, the decoder still produces false).658        val withFieldFalse = AdoptJson.decodeFromString(659            AdoptionFrame.serializer(),660            "{\"type\":\"ADOPT_GRANT\",\"root_key_hex\":\"${"11".repeat(32)}\",\"vault_id\":\"${"22".repeat(32)}\",\"snapshot_capable\":false,\"source\":{\"device_id\":\"source\",\"device_name\":\"Source\",\"device_type\":\"extension\",\"long_term_public_key_hex\":\"${"ab".repeat(32)}\"}}",661        ) as AdoptionFrame.Grant662        assertFalse(663            "explicit false must decode to false",664            withFieldFalse.snapshotCapable,665        )666    }667668    @Test fun requestAdoption_invokes_onGrant_with_decoded_grant() = runBlocking {669        // Receiver captures the grant seen on the wire via the onGrant callback.670        // Source (RecorderSource) sends an ADOPT_GRANT that echoes the receiver's671        // ADOPT_REQUEST.snapshot_capable, which the receiver-side callback then672        // surfaces for the runRelayAdoptionReceiverSession gate.673        val (receiverCh, sourceCh) = memoryPair()674        val rootKeyHex = "55".repeat(32)675        val vaultId = vaultIdFor(rootKeyHex)676        val receiverIdentity = identity("receiver").copy(deviceType = DeviceType.ANDROID)677        val receiver = RecorderReceiver(678            "Receiver",679            DeviceType.ANDROID,680            vaultId,681            derived = receiverIdentity,682            localLicense = null,683        )684        val source = RecorderSource(identity("source"), rootKeyHex, vaultId, license = "PPK-test")685        val seenGrants = mutableListOf<AdoptionFrame.Grant>()686687        val recvJob = async {688            requestAdoption(689                receiverCh,690                receiver,691                onGrant = { grant -> seenGrants += grant },692            )693        }694        val grantJob = async {695            val request = receiveFrame(sourceCh) as AdoptionFrame.Request696            assertTrue(697                "receiver must advertise snapshot_capable in ADOPT_REQUEST",698                request.snapshotCapable == true,699            )700            grantAdoption(sourceCh, request, source) { 0L }701        }702        recvJob.await()703        grantJob.await()704705        assertEquals(1, seenGrants.size)706        assertEquals(rootKeyHex, seenGrants.single().rootKeyHex)707        // grantAdoption echoes request.snapshot_capable === true on the wire, so708        // the receiver's onGrant callback sees snapshot_capable: true and the709        // receiver's runRelayAdoptionReceiverSession enters the 15s inline wait.710        // The serializer tests above cover the explicit-false case.711        assertTrue(712            "grant must echo request.snapshot_capable === true",713            seenGrants.single().snapshotCapable,714        )715    }716}