VaultAdoptTest.kt

23.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        override fun generateDeviceId(): String = "receiver-ephemeral"68        override suspend fun confirmSas(): Boolean = sasConfirmed69        override suspend fun createVaultFromRootKey(rootKeyHex: String): String {70            createCalledWith = rootKeyHex71            if (createThrows) throw IllegalStateException("commit_failed")72            return vaultId ?: error("no vault id")73        }74        override suspend fun deriveLocalIdentity(): LocalDeviceIdentity = derived ?: error("no identity")75        override suspend fun clearPairedDeviceRevocation(deviceId: String) {76            revocationsCleared += deviceId77        }78        override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }79        override suspend fun applyLicense(license: String) { licenseApplied = license }80        override suspend fun license(): String? = localLicense81        override fun onError(error: String) { errors += error }82    }8384    private class RecorderSource(85        override val sourceIdentity: LocalDeviceIdentity,86        private val rootKeyHex: String,87        private val vaultId: String,88        license: String? = "PPK-test",89        private val consent: Boolean = true,90    ) : AdoptionSourceDeps {91        private var storedLicense: String? = license92        val upserted = mutableListOf<PairedDevice>()93        var licenseApplied: String? = null94        var consentAsked = false95        var rootRequested = false96        override suspend fun rootKeyHex(): String {97            rootRequested = true98            return rootKeyHex99        }100        override suspend fun vaultId(): String = vaultId101        override suspend fun license(): String? = storedLicense102        override suspend fun applyLicense(license: String) {103            licenseApplied = license104            storedLicense = license105        }106        override suspend fun onConsent(request: AdoptionFrame.Request): Boolean {107            consentAsked = true108            return consent109        }110        override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }111    }112113    @Test fun completes_request_through_mutual_completion_acknowledgements() = runBlocking {114        val (receiverCh, sourceCh) = memoryPair()115        val rootKeyHex = "11".repeat(32)116        val vaultId = vaultIdFor(rootKeyHex)117        val receiverIdentity = identity("receiver").copy(deviceType = DeviceType.ANDROID)118        val receiver = RecorderReceiver(119            "Receiver",120            DeviceType.ANDROID,121            vaultId,122            derived = receiverIdentity,123            localLicense = null,124        )125        val source = RecorderSource(identity("source"), rootKeyHex, vaultId, license = "PPK-test")126127        val recvJob = async { requestAdoption(receiverCh, receiver) { 111L } }128        val grantJob = async {129            val request = receiveFrame(sourceCh) as AdoptionFrame.Request130            grantAdoption(sourceCh, request, source) { 222L }131        }132        recvJob.await()133        grantJob.await()134135        assertEquals(rootKeyHex, receiver.createCalledWith)136        assertEquals("PPK-test", receiver.licenseApplied)137        assertEquals(listOf("source-id"), receiver.revocationsCleared)138        assertEquals("source-id", receiver.upserted.single().deviceId)139        assertEquals("receiver-id", source.upserted.single().deviceId)140        assertEquals("android", source.upserted.single().deviceType)141    }142143    @Test fun rejects_without_creating_or_pairing_when_neither_peer_has_pro() = runBlocking {144        val (receiverCh, sourceCh) = memoryPair()145        val rootKeyHex = "12".repeat(32)146        val vaultId = vaultIdFor(rootKeyHex)147        val receiver = RecorderReceiver(148            "Free receiver",149            DeviceType.ANDROID,150            vaultId,151            localLicense = null,152        )153        val source = RecorderSource(154            identity("free-source"),155            rootKeyHex,156            vaultId,157            license = null,158        )159160        val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }161        val sourceJob = async {162            val request = receiveFrame(sourceCh) as AdoptionFrame.Request163            runCatching { grantAdoption(sourceCh, request, source) { 0L } }164        }165166        assertNull(receiverJob.await())167        val sourceOutcome = sourceJob.await()168        assertTrue(sourceOutcome.isFailure)169        assertEquals("ADOPT_PRO_REQUIRED", sourceOutcome.exceptionOrNull()?.message)170        assertEquals(listOf("ADOPT_PRO_REQUIRED"), receiver.errors)171        assertNull(receiver.createCalledWith)172        assertTrue(receiver.upserted.isEmpty())173        assertTrue(source.upserted.isEmpty())174    }175176    @Test fun receiver_pro_license_is_transferred_to_a_free_source() = runBlocking {177        val (receiverCh, sourceCh) = memoryPair()178        val rootKeyHex = "13".repeat(32)179        val vaultId = vaultIdFor(rootKeyHex)180        val receiver = RecorderReceiver(181            "Pro receiver",182            DeviceType.ANDROID,183            vaultId,184            derived = identity("receiver").copy(deviceType = DeviceType.ANDROID),185            localLicense = "PPK-receiver",186        )187        val source = RecorderSource(188            identity("free-source"),189            rootKeyHex,190            vaultId,191            license = null,192        )193194        val receiverJob = async { requestAdoption(receiverCh, receiver) { 1L } }195        val sourceJob = async {196            val request = receiveFrame(sourceCh) as AdoptionFrame.Request197            grantAdoption(sourceCh, request, source) { 2L }198        }199200        assertEquals("free-source-id", receiverJob.await()?.deviceId)201        assertEquals("receiver-id", sourceJob.await()?.deviceId)202        assertEquals("PPK-receiver", source.licenseApplied)203    }204205    /**206     * The constraint from `PEACH_RELAY_AND_DEVICEID_FIXES.md` Part 2: the label207     * on ADOPT_REQUEST is generated before any key exists, so persisting it as208     * an identity would key trust on a value with no continuity between209     * attempts. What lands in the store must come from ADOPT_IDENTITY.210     */211    @Test fun request_label_is_never_what_gets_persisted() = runBlocking {212        val (receiverCh, sourceCh) = memoryPair()213        val rootKeyHex = "55".repeat(32)214        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))215        // Deliberately unlike the identity the receiver will present.216        val request = AdoptionFrame.Request("ephemeral-label-not-an-identity", "Receiver", "extension")217218        val grantJob = async { grantAdoption(sourceCh, request, source) { 0L } }219        sendFrame(receiverCh, AdoptionFrame.Confirm(true))220        receiveFrame(receiverCh) // the grant221        sendFrame(222            receiverCh,223            AdoptionFrame.Identity(224                deviceId = "receiver-id",225                deviceName = "Receiver",226                deviceType = "extension",227                longTermPublicKeyHex = "aa".repeat(32),228                signingPublicKeyHex = "bb".repeat(32),229            ),230        )231        receiveFrame(receiverCh) // ADOPT_COMPLETE232        sendFrame(receiverCh, AdoptionFrame.Applied)233        grantJob.await()234235        val persisted = source.upserted.single()236        assertEquals("receiver-id", persisted.deviceId)237        assertEquals("aa".repeat(32), persisted.longTermPublicKeyHex)238        assertTrue(239            "the ephemeral request label must not reach the paired-device store",240            source.upserted.none { it.deviceId == "ephemeral-label-not-an-identity" },241        )242    }243244    @Test fun request_carries_receiver_device_type() = runBlocking {245        val (receiverCh, sourceCh) = memoryPair()246        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)247        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }248        val request = receiveFrame(sourceCh) as AdoptionFrame.Request249        recvJob.cancel()250251        assertEquals("android", request.deviceType)252        assertEquals("receiver-ephemeral", request.sessionRequestId)253        assertEquals("Receiver", request.deviceName)254    }255256    @Test fun denied_consent_grants_nothing() = runBlocking {257        val (receiverCh, sourceCh) = memoryPair()258        val rootKeyHex = "33".repeat(32)259        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultIdFor(rootKeyHex))260        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex), consent = false)261262        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }263        val request = receiveFrame(sourceCh) as AdoptionFrame.Request264        val granted = grantAdoption(sourceCh, request, source) { 0L }265        recvJob.cancel()266267        assertNull(granted)268        assertTrue(source.consentAsked)269        assertFalse(source.rootRequested)270        assertTrue(source.upserted.isEmpty())271        assertNull(receiver.createCalledWith)272    }273274    @Test fun receiver_sas_rejection_grants_no_root_key() = runBlocking {275        val (receiverCh, sourceCh) = memoryPair()276        val rootKeyHex = "66".repeat(32)277        val receiver = RecorderReceiver(278            "Receiver",279            DeviceType.ANDROID,280            vaultIdFor(rootKeyHex),281            sasConfirmed = false,282        )283        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))284285        val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }286        val request = receiveFrame(sourceCh) as AdoptionFrame.Request287        val sourceJob = async { grantAdoption(sourceCh, request, source) { 0L } }288289        assertNull(receiverJob.await())290        assertNull(sourceJob.await())291        assertFalse(source.rootRequested)292        assertNull(receiver.createCalledWith)293        assertTrue(receiver.revocationsCleared.isEmpty())294        assertEquals(listOf("ADOPT_SAS_REJECTED"), receiver.errors)295    }296297    @Test fun invalid_root_key_hex_aborts_as_unexpected_frame() = runBlocking {298        val (receiverCh, sourceCh) = memoryPair()299        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)300        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }301        receiveFrame(sourceCh)302        sendFrame(sourceCh, AdoptionFrame.Grant(303            rootKeyHex = "bad",304            vaultId = "00".repeat(32),305            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),306        ))307        recvJob.await()308309        assertEquals(listOf("ADOPT_UNEXPECTED_FRAME"), receiver.errors)310        assertNull(receiver.createCalledWith)311    }312313    @Test fun vault_id_mismatch_aborts_before_vault_creation() = runBlocking {314        val (receiverCh, sourceCh) = memoryPair()315        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)316        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }317        receiveFrame(sourceCh)318        sendFrame(sourceCh, AdoptionFrame.Grant(319            rootKeyHex = "22".repeat(32),320            vaultId = "00".repeat(32),321            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),322        ))323        recvJob.await()324325        assertEquals(listOf("ADOPT_VAULT_ID_MISMATCH"), receiver.errors)326        assertNull(receiver.createCalledWith)327    }328329    @Test fun source_waits_for_identity_before_persisting_receiver() = runBlocking {330        val (receiverCh, sourceCh) = memoryPair()331        val rootKeyHex = "33".repeat(32)332        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))333        val request = AdoptionFrame.Request("receiver-ephemeral", "Receiver", "extension")334335        sendFrame(receiverCh, AdoptionFrame.Confirm(true))336        val result = withTimeoutOrNull(150) {337            // No receiver answers the ADOPT_IDENTITY read, so grant must hang, not persist.338            grantAdoption(sourceCh, request, source) { 0L }339        }340341        assertNull(result)342        assertTrue(source.upserted.isEmpty())343    }344345    @Test fun receiver_does_not_persist_source_without_complete() = runBlocking {346        val (receiverCh, sourceCh) = memoryPair()347        val rootKeyHex = "77".repeat(32)348        val vaultId = vaultIdFor(rootKeyHex)349        val receiver = RecorderReceiver(350            "Receiver",351            DeviceType.ANDROID,352            vaultId,353            derived = identity("receiver").copy(deviceType = DeviceType.ANDROID),354        )355        val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }356357        receiveFrame(sourceCh) // ADOPT_REQUEST358        receiveFrame(sourceCh) // ADOPT_CONFIRM359        sendFrame(360            sourceCh,361            AdoptionFrame.Grant(362                rootKeyHex = rootKeyHex,363                vaultId = vaultId,364                source = AdoptionFrame.Source(365                    "source-id",366                    "Source",367                    "extension",368                    "ab".repeat(32),369                ),370            ),371        )372        receiveFrame(sourceCh) // ADOPT_IDENTITY373        sendFrame(sourceCh, AdoptionFrame.Confirm(true))374375        assertNull(receiverJob.await())376        assertEquals(listOf("ADOPT_COMPLETION_MISSING"), receiver.errors)377        assertTrue(receiver.revocationsCleared.isEmpty())378        assertTrue(receiver.upserted.isEmpty())379    }380381    @Test fun receiver_safety_policy_runs_before_vault_creation() = runBlocking {382        val (receiverCh, sourceCh) = memoryPair()383        val rootKeyHex = "78".repeat(32)384        val vaultId = vaultIdFor(rootKeyHex)385        val receiver = object : RecorderReceiver(386            "Receiver",387            DeviceType.ANDROID,388            vaultId,389            derived = identity("receiver").copy(deviceType = DeviceType.ANDROID),390        ) {391            override suspend fun validateSource(source: AdoptionFrame.Source) {392                throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")393            }394        }395        val receiverJob = async {396            runCatching { requestAdoption(receiverCh, receiver) { 0L } }397        }398399        receiveFrame(sourceCh) // ADOPT_REQUEST400        receiveFrame(sourceCh) // ADOPT_CONFIRM401        sendFrame(402            sourceCh,403            AdoptionFrame.Grant(404                rootKeyHex = rootKeyHex,405                vaultId = vaultId,406                source = AdoptionFrame.Source(407                    "new-source",408                    "Source",409                    "extension",410                    "ab".repeat(32),411                ),412            ),413        )414415        val outcome = receiverJob.await()416        assertTrue(outcome.isFailure)417        assertEquals("JOIN_POPULATED_VAULT_BLOCKED", outcome.exceptionOrNull()?.message)418        assertNull(receiver.createCalledWith)419    }420421    @Test fun does_not_apply_license_when_vault_creation_fails() = runBlocking {422        val (receiverCh, sourceCh) = memoryPair()423        val rootKeyHex = "44".repeat(32)424        val vaultId = vaultIdFor(rootKeyHex)425        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, createThrows = true)426        val recvJob = async {427            runCatching { requestAdoption(receiverCh, receiver) { 0L } }428        }429        receiveFrame(sourceCh)430        sendFrame(sourceCh, AdoptionFrame.Grant(431            rootKeyHex = rootKeyHex,432            vaultId = vaultId,433            license = "PPK-test",434            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),435        ))436        val outcome = recvJob.await()437438        assertTrue(outcome.isFailure)439        assertNull(receiver.licenseApplied)440    }441442    @Test fun validator_rejects_short_and_uppercase_root_keys() {443        assertTrue(validateRootKeyHex("ab".repeat(32)))444        assertFalse(validateRootKeyHex("ab".repeat(31)))445        assertFalse(validateRootKeyHex("AB".repeat(32)))446        assertFalse(validateRootKeyHex("zz".repeat(32)))447        assertFalse(validateRootKeyHex(null))448    }449450    @Test fun grant_omits_absent_optionals_like_json_stringify() {451        val grant = AdoptionFrame.Grant(452            rootKeyHex = "11".repeat(32),453            vaultId = "22".repeat(32),454            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),455        )456        val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), grant)457458        assertFalse(json.contains("license"))459        assertFalse(json.contains("ws_port"))460        assertFalse(json.contains("signing_public_key_hex"))461        assertTrue(json.contains("\"type\":\"ADOPT_GRANT\""))462        assertTrue(json.contains("\"root_key_hex\""))463    }464465    @Test fun grant_json_carries_snapshot_capable_true_when_request_advertised_it() {466        val grant = AdoptionFrame.Grant(467            rootKeyHex = "11".repeat(32),468            vaultId = "22".repeat(32),469            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),470            snapshotCapable = true,471        )472        val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), grant)473        assertTrue(474            "explicit snapshot_capable: true must serialize",475            json.contains("\"snapshot_capable\":true"),476        )477    }478479    @Test fun grant_decodes_snapshot_capable_false_when_field_absent() {480        // Android wire shape diverges from browser here: AdoptJson uses481        // encodeDefaults = false, so a Grant constructed with snapshotCapable = false482        // (the Kotlin default) serializes WITHOUT the field. The decoder then483        // defaults it back to false. This is wire-compatible with both ends:484        // pre-C sources that never set the field decode to false (receiver skips485        // 15s wait), and v4.6.1 browser sources that DO set it to false... actually486        // browser always sends explicit true OR false; Android only sends true.487        // Both shapes decode correctly on the receiver side because the receiver's488        // gate is `if (sourceCanSendSnapshot)` — missing field, false field, and489        // undefined all flow to "skip wait". Documented as a deliberate divergence.490        val withoutField = AdoptJson.decodeFromString(491            AdoptionFrame.serializer(),492            "{\"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)}\"}}",493        ) as AdoptionFrame.Grant494        assertFalse(495            "missing field must decode to default false (receiver skips 15s wait)",496            withoutField.snapshotCapable,497        )498        // And explicit-false round-trips (when a future change makes Android send499        // explicit false, the decoder still produces false).500        val withFieldFalse = AdoptJson.decodeFromString(501            AdoptionFrame.serializer(),502            "{\"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)}\"}}",503        ) as AdoptionFrame.Grant504        assertFalse(505            "explicit false must decode to false",506            withFieldFalse.snapshotCapable,507        )508    }509510    @Test fun requestAdoption_invokes_onGrant_with_decoded_grant() = runBlocking {511        // Receiver captures the grant seen on the wire via the onGrant callback.512        // Source (RecorderSource) sends an ADOPT_GRANT that echoes the receiver's513        // ADOPT_REQUEST.snapshot_capable, which the receiver-side callback then514        // surfaces for the runRelayAdoptionReceiverSession gate.515        val (receiverCh, sourceCh) = memoryPair()516        val rootKeyHex = "55".repeat(32)517        val vaultId = vaultIdFor(rootKeyHex)518        val receiverIdentity = identity("receiver").copy(deviceType = DeviceType.ANDROID)519        val receiver = RecorderReceiver(520            "Receiver",521            DeviceType.ANDROID,522            vaultId,523            derived = receiverIdentity,524            localLicense = null,525        )526        val source = RecorderSource(identity("source"), rootKeyHex, vaultId, license = "PPK-test")527        val seenGrants = mutableListOf<AdoptionFrame.Grant>()528529        val recvJob = async {530            requestAdoption(531                receiverCh,532                receiver,533                onGrant = { grant -> seenGrants += grant },534            )535        }536        val grantJob = async {537            val request = receiveFrame(sourceCh) as AdoptionFrame.Request538            assertTrue(539                "receiver must advertise snapshot_capable in ADOPT_REQUEST",540                request.snapshotCapable == true,541            )542            grantAdoption(sourceCh, request, source) { 0L }543        }544        recvJob.await()545        grantJob.await()546547        assertEquals(1, seenGrants.size)548        assertEquals(rootKeyHex, seenGrants.single().rootKeyHex)549        // grantAdoption echoes request.snapshot_capable === true on the wire, so550        // the receiver's onGrant callback sees snapshot_capable: true and the551        // receiver's runRelayAdoptionReceiverSession enters the 15s inline wait.552        // The serializer tests above cover the explicit-false case.553        assertTrue(554            "grant must echo request.snapshot_capable === true",555            seenGrants.single().snapshotCapable,556        )557    }558}