VaultAdoptTest.kt

15.9 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    ) : AdoptionReceiverDeps {61        var createCalledWith: String? = null62        var licenseApplied: String? = null63        val revocationsCleared = mutableListOf<String>()64        val upserted = mutableListOf<PairedDevice>()65        val errors = mutableListOf<String>()66        override fun generateDeviceId(): String = "receiver-ephemeral"67        override suspend fun confirmSas(): Boolean = sasConfirmed68        override suspend fun createVaultFromRootKey(rootKeyHex: String): String {69            createCalledWith = rootKeyHex70            if (createThrows) throw IllegalStateException("commit_failed")71            return vaultId ?: error("no vault id")72        }73        override suspend fun deriveLocalIdentity(): LocalDeviceIdentity = derived ?: error("no identity")74        override suspend fun clearPairedDeviceRevocation(deviceId: String) {75            revocationsCleared += deviceId76        }77        override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }78        override suspend fun applyLicense(license: String) { licenseApplied = license }79        override fun onError(error: String) { errors += error }80    }8182    private class RecorderSource(83        override val sourceIdentity: LocalDeviceIdentity,84        private val rootKeyHex: String,85        private val vaultId: String,86        private val license: String? = null,87        private val consent: Boolean = true,88    ) : AdoptionSourceDeps {89        val upserted = mutableListOf<PairedDevice>()90        var consentAsked = false91        var rootRequested = false92        override suspend fun rootKeyHex(): String {93            rootRequested = true94            return rootKeyHex95        }96        override suspend fun vaultId(): String = vaultId97        override suspend fun license(): String? = license98        override suspend fun onConsent(request: AdoptionFrame.Request): Boolean {99            consentAsked = true100            return consent101        }102        override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }103    }104105    @Test fun completes_request_through_mutual_completion_acknowledgements() = runBlocking {106        val (receiverCh, sourceCh) = memoryPair()107        val rootKeyHex = "11".repeat(32)108        val vaultId = vaultIdFor(rootKeyHex)109        val receiverIdentity = identity("receiver").copy(deviceType = DeviceType.ANDROID)110        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, derived = receiverIdentity)111        val source = RecorderSource(identity("source"), rootKeyHex, vaultId, license = "PPK-test")112113        val recvJob = async { requestAdoption(receiverCh, receiver) { 111L } }114        val grantJob = async {115            val request = receiveFrame(sourceCh) as AdoptionFrame.Request116            grantAdoption(sourceCh, request, source) { 222L }117        }118        recvJob.await()119        grantJob.await()120121        assertEquals(rootKeyHex, receiver.createCalledWith)122        assertEquals("PPK-test", receiver.licenseApplied)123        assertEquals(listOf("source-id"), receiver.revocationsCleared)124        assertEquals("source-id", receiver.upserted.single().deviceId)125        assertEquals("receiver-id", source.upserted.single().deviceId)126        assertEquals("android", source.upserted.single().deviceType)127    }128129    /**130     * The constraint from `PEACH_RELAY_AND_DEVICEID_FIXES.md` Part 2: the label131     * on ADOPT_REQUEST is generated before any key exists, so persisting it as132     * an identity would key trust on a value with no continuity between133     * attempts. What lands in the store must come from ADOPT_IDENTITY.134     */135    @Test fun request_label_is_never_what_gets_persisted() = runBlocking {136        val (receiverCh, sourceCh) = memoryPair()137        val rootKeyHex = "55".repeat(32)138        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))139        // Deliberately unlike the identity the receiver will present.140        val request = AdoptionFrame.Request("ephemeral-label-not-an-identity", "Receiver", "extension")141142        val grantJob = async { grantAdoption(sourceCh, request, source) { 0L } }143        sendFrame(receiverCh, AdoptionFrame.Confirm(true))144        receiveFrame(receiverCh) // the grant145        sendFrame(146            receiverCh,147            AdoptionFrame.Identity(148                deviceId = "receiver-id",149                deviceName = "Receiver",150                deviceType = "extension",151                longTermPublicKeyHex = "aa".repeat(32),152                signingPublicKeyHex = "bb".repeat(32),153            ),154        )155        receiveFrame(receiverCh) // ADOPT_COMPLETE156        sendFrame(receiverCh, AdoptionFrame.Applied)157        grantJob.await()158159        val persisted = source.upserted.single()160        assertEquals("receiver-id", persisted.deviceId)161        assertEquals("aa".repeat(32), persisted.longTermPublicKeyHex)162        assertTrue(163            "the ephemeral request label must not reach the paired-device store",164            source.upserted.none { it.deviceId == "ephemeral-label-not-an-identity" },165        )166    }167168    @Test fun request_carries_receiver_device_type() = runBlocking {169        val (receiverCh, sourceCh) = memoryPair()170        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)171        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }172        val request = receiveFrame(sourceCh) as AdoptionFrame.Request173        recvJob.cancel()174175        assertEquals("android", request.deviceType)176        assertEquals("receiver-ephemeral", request.sessionRequestId)177        assertEquals("Receiver", request.deviceName)178    }179180    @Test fun denied_consent_grants_nothing() = runBlocking {181        val (receiverCh, sourceCh) = memoryPair()182        val rootKeyHex = "33".repeat(32)183        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultIdFor(rootKeyHex))184        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex), consent = false)185186        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }187        val request = receiveFrame(sourceCh) as AdoptionFrame.Request188        val granted = grantAdoption(sourceCh, request, source) { 0L }189        recvJob.cancel()190191        assertNull(granted)192        assertTrue(source.consentAsked)193        assertFalse(source.rootRequested)194        assertTrue(source.upserted.isEmpty())195        assertNull(receiver.createCalledWith)196    }197198    @Test fun receiver_sas_rejection_grants_no_root_key() = runBlocking {199        val (receiverCh, sourceCh) = memoryPair()200        val rootKeyHex = "66".repeat(32)201        val receiver = RecorderReceiver(202            "Receiver",203            DeviceType.ANDROID,204            vaultIdFor(rootKeyHex),205            sasConfirmed = false,206        )207        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))208209        val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }210        val request = receiveFrame(sourceCh) as AdoptionFrame.Request211        val sourceJob = async { grantAdoption(sourceCh, request, source) { 0L } }212213        assertNull(receiverJob.await())214        assertNull(sourceJob.await())215        assertFalse(source.rootRequested)216        assertNull(receiver.createCalledWith)217        assertTrue(receiver.revocationsCleared.isEmpty())218        assertEquals(listOf("ADOPT_SAS_REJECTED"), receiver.errors)219    }220221    @Test fun invalid_root_key_hex_aborts_as_unexpected_frame() = runBlocking {222        val (receiverCh, sourceCh) = memoryPair()223        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)224        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }225        receiveFrame(sourceCh)226        sendFrame(sourceCh, AdoptionFrame.Grant(227            rootKeyHex = "bad",228            vaultId = "00".repeat(32),229            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),230        ))231        recvJob.await()232233        assertEquals(listOf("ADOPT_UNEXPECTED_FRAME"), receiver.errors)234        assertNull(receiver.createCalledWith)235    }236237    @Test fun vault_id_mismatch_aborts_before_vault_creation() = runBlocking {238        val (receiverCh, sourceCh) = memoryPair()239        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)240        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }241        receiveFrame(sourceCh)242        sendFrame(sourceCh, AdoptionFrame.Grant(243            rootKeyHex = "22".repeat(32),244            vaultId = "00".repeat(32),245            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),246        ))247        recvJob.await()248249        assertEquals(listOf("ADOPT_VAULT_ID_MISMATCH"), receiver.errors)250        assertNull(receiver.createCalledWith)251    }252253    @Test fun source_waits_for_identity_before_persisting_receiver() = runBlocking {254        val (receiverCh, sourceCh) = memoryPair()255        val rootKeyHex = "33".repeat(32)256        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))257        val request = AdoptionFrame.Request("receiver-ephemeral", "Receiver", "extension")258259        sendFrame(receiverCh, AdoptionFrame.Confirm(true))260        val result = withTimeoutOrNull(150) {261            // No receiver answers the ADOPT_IDENTITY read, so grant must hang, not persist.262            grantAdoption(sourceCh, request, source) { 0L }263        }264265        assertNull(result)266        assertTrue(source.upserted.isEmpty())267    }268269    @Test fun receiver_does_not_persist_source_without_complete() = runBlocking {270        val (receiverCh, sourceCh) = memoryPair()271        val rootKeyHex = "77".repeat(32)272        val vaultId = vaultIdFor(rootKeyHex)273        val receiver = RecorderReceiver(274            "Receiver",275            DeviceType.ANDROID,276            vaultId,277            derived = identity("receiver").copy(deviceType = DeviceType.ANDROID),278        )279        val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }280281        receiveFrame(sourceCh) // ADOPT_REQUEST282        receiveFrame(sourceCh) // ADOPT_CONFIRM283        sendFrame(284            sourceCh,285            AdoptionFrame.Grant(286                rootKeyHex = rootKeyHex,287                vaultId = vaultId,288                source = AdoptionFrame.Source(289                    "source-id",290                    "Source",291                    "extension",292                    "ab".repeat(32),293                ),294            ),295        )296        receiveFrame(sourceCh) // ADOPT_IDENTITY297        sendFrame(sourceCh, AdoptionFrame.Confirm(true))298299        assertNull(receiverJob.await())300        assertEquals(listOf("ADOPT_COMPLETION_MISSING"), receiver.errors)301        assertTrue(receiver.revocationsCleared.isEmpty())302        assertTrue(receiver.upserted.isEmpty())303    }304305    @Test fun receiver_safety_policy_runs_before_vault_creation() = runBlocking {306        val (receiverCh, sourceCh) = memoryPair()307        val rootKeyHex = "78".repeat(32)308        val vaultId = vaultIdFor(rootKeyHex)309        val receiver = object : RecorderReceiver(310            "Receiver",311            DeviceType.ANDROID,312            vaultId,313            derived = identity("receiver").copy(deviceType = DeviceType.ANDROID),314        ) {315            override suspend fun validateSource(source: AdoptionFrame.Source) {316                throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")317            }318        }319        val receiverJob = async {320            runCatching { requestAdoption(receiverCh, receiver) { 0L } }321        }322323        receiveFrame(sourceCh) // ADOPT_REQUEST324        receiveFrame(sourceCh) // ADOPT_CONFIRM325        sendFrame(326            sourceCh,327            AdoptionFrame.Grant(328                rootKeyHex = rootKeyHex,329                vaultId = vaultId,330                source = AdoptionFrame.Source(331                    "new-source",332                    "Source",333                    "extension",334                    "ab".repeat(32),335                ),336            ),337        )338339        val outcome = receiverJob.await()340        assertTrue(outcome.isFailure)341        assertEquals("JOIN_POPULATED_VAULT_BLOCKED", outcome.exceptionOrNull()?.message)342        assertNull(receiver.createCalledWith)343    }344345    @Test fun does_not_apply_license_when_vault_creation_fails() = runBlocking {346        val (receiverCh, sourceCh) = memoryPair()347        val rootKeyHex = "44".repeat(32)348        val vaultId = vaultIdFor(rootKeyHex)349        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, createThrows = true)350        val recvJob = async {351            runCatching { requestAdoption(receiverCh, receiver) { 0L } }352        }353        receiveFrame(sourceCh)354        sendFrame(sourceCh, AdoptionFrame.Grant(355            rootKeyHex = rootKeyHex,356            vaultId = vaultId,357            license = "PPK-test",358            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),359        ))360        val outcome = recvJob.await()361362        assertTrue(outcome.isFailure)363        assertNull(receiver.licenseApplied)364    }365366    @Test fun validator_rejects_short_and_uppercase_root_keys() {367        assertTrue(validateRootKeyHex("ab".repeat(32)))368        assertFalse(validateRootKeyHex("ab".repeat(31)))369        assertFalse(validateRootKeyHex("AB".repeat(32)))370        assertFalse(validateRootKeyHex("zz".repeat(32)))371        assertFalse(validateRootKeyHex(null))372    }373374    @Test fun grant_omits_absent_optionals_like_json_stringify() {375        val grant = AdoptionFrame.Grant(376            rootKeyHex = "11".repeat(32),377            vaultId = "22".repeat(32),378            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),379        )380        val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), grant)381382        assertFalse(json.contains("license"))383        assertFalse(json.contains("ws_port"))384        assertFalse(json.contains("signing_public_key_hex"))385        assertTrue(json.contains("\"type\":\"ADOPT_GRANT\""))386        assertTrue(json.contains("\"root_key_hex\""))387    }388}