ChirpAdoptTest.kt

9.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 `chirp-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 ChirpAdoptTest {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) = LocalChirpIdentity(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 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: LocalChirpIdentity? = null,59    ) : AdoptionReceiverDeps {60        var createCalledWith: String? = null61        var licenseApplied: String? = null62        val upserted = mutableListOf<PairedDevice>()63        val errors = mutableListOf<String>()64        override fun generateDeviceId(): String = "receiver-ephemeral"65        override suspend fun createVaultFromRootKey(rootKeyHex: String): String {66            createCalledWith = rootKeyHex67            if (createThrows) throw IllegalStateException("commit_failed")68            return vaultId ?: error("no vault id")69        }70        override suspend fun deriveLocalIdentity(): LocalChirpIdentity = derived ?: error("no identity")71        override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }72        override suspend fun applyLicense(license: String) { licenseApplied = license }73        override fun onError(error: String) { errors += error }74    }7576    private class RecorderSource(77        override val sourceIdentity: LocalChirpIdentity,78        private val rootKeyHex: String,79        private val vaultId: String,80        private val license: String? = null,81        private val consent: Boolean = true,82    ) : AdoptionSourceDeps {83        val upserted = mutableListOf<PairedDevice>()84        var consentAsked = false85        override suspend fun rootKeyHex(): String = rootKeyHex86        override suspend fun vaultId(): String = vaultId87        override suspend fun license(): String? = license88        override suspend fun onConsent(request: AdoptionFrame.Request): Boolean {89            consentAsked = true90            return consent91        }92        override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }93    }9495    @Test fun completes_request_grant_identity_round_trip() = runBlocking {96        val (receiverCh, sourceCh) = memoryPair()97        val rootKeyHex = "11".repeat(32)98        val vaultId = vaultIdFor(rootKeyHex)99        val receiverIdentity = identity("receiver").copy(deviceType = DeviceType.ANDROID)100        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, derived = receiverIdentity)101        val source = RecorderSource(identity("source"), rootKeyHex, vaultId, license = "PPK-test")102103        val recvJob = async { requestAdoption(receiverCh, receiver) { 111L } }104        val grantJob = async {105            val request = receiveFrame(sourceCh) as AdoptionFrame.Request106            grantAdoption(sourceCh, request, source) { 222L }107        }108        recvJob.await()109        grantJob.await()110111        assertEquals(rootKeyHex, receiver.createCalledWith)112        assertEquals("PPK-test", receiver.licenseApplied)113        assertEquals("source-id", receiver.upserted.single().deviceId)114        assertEquals("receiver-id", source.upserted.single().deviceId)115        assertEquals("android", source.upserted.single().deviceType)116    }117118    @Test fun request_carries_receiver_device_type() = runBlocking {119        val (receiverCh, sourceCh) = memoryPair()120        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)121        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }122        val request = receiveFrame(sourceCh) as AdoptionFrame.Request123        recvJob.cancel()124125        assertEquals("android", request.deviceType)126        assertEquals("receiver-ephemeral", request.deviceId)127        assertEquals("Receiver", request.deviceName)128    }129130    @Test fun denied_consent_grants_nothing() = runBlocking {131        val (receiverCh, sourceCh) = memoryPair()132        val rootKeyHex = "33".repeat(32)133        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultIdFor(rootKeyHex))134        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex), consent = false)135136        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }137        val request = receiveFrame(sourceCh) as AdoptionFrame.Request138        val granted = grantAdoption(sourceCh, request, source) { 0L }139        recvJob.cancel()140141        assertNull(granted)142        assertTrue(source.consentAsked)143        assertTrue(source.upserted.isEmpty())144        assertNull(receiver.createCalledWith)145    }146147    @Test fun invalid_root_key_hex_aborts_as_unexpected_frame() = runBlocking {148        val (receiverCh, sourceCh) = memoryPair()149        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)150        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }151        receiveFrame(sourceCh)152        sendFrame(sourceCh, AdoptionFrame.Grant(153            rootKeyHex = "bad",154            vaultId = "00".repeat(32),155            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),156        ))157        recvJob.await()158159        assertEquals(listOf("ADOPT_UNEXPECTED_FRAME"), receiver.errors)160        assertNull(receiver.createCalledWith)161    }162163    @Test fun vault_id_mismatch_aborts_before_vault_creation() = runBlocking {164        val (receiverCh, sourceCh) = memoryPair()165        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)166        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }167        receiveFrame(sourceCh)168        sendFrame(sourceCh, AdoptionFrame.Grant(169            rootKeyHex = "22".repeat(32),170            vaultId = "00".repeat(32),171            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),172        ))173        recvJob.await()174175        assertEquals(listOf("ADOPT_VAULT_ID_MISMATCH"), receiver.errors)176        assertNull(receiver.createCalledWith)177    }178179    @Test fun source_waits_for_identity_before_persisting_receiver() = runBlocking {180        val (_, sourceCh) = memoryPair()181        val rootKeyHex = "33".repeat(32)182        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))183        val request = AdoptionFrame.Request("receiver-ephemeral", "Receiver", "extension")184185        val result = withTimeoutOrNull(150) {186            // No receiver answers the ADOPT_IDENTITY read, so grant must hang, not persist.187            grantAdoption(sourceCh, request, source) { 0L }188        }189190        assertNull(result)191        assertTrue(source.upserted.isEmpty())192    }193194    @Test fun does_not_apply_license_when_vault_creation_fails() = runBlocking {195        val (receiverCh, sourceCh) = memoryPair()196        val rootKeyHex = "44".repeat(32)197        val vaultId = vaultIdFor(rootKeyHex)198        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, createThrows = true)199        val recvJob = async {200            runCatching { requestAdoption(receiverCh, receiver) { 0L } }201        }202        receiveFrame(sourceCh)203        sendFrame(sourceCh, AdoptionFrame.Grant(204            rootKeyHex = rootKeyHex,205            vaultId = vaultId,206            license = "PPK-test",207            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),208        ))209        val outcome = recvJob.await()210211        assertTrue(outcome.isFailure)212        assertNull(receiver.licenseApplied)213    }214215    @Test fun validator_rejects_short_and_uppercase_root_keys() {216        assertTrue(validateRootKeyHex("ab".repeat(32)))217        assertFalse(validateRootKeyHex("ab".repeat(31)))218        assertFalse(validateRootKeyHex("AB".repeat(32)))219        assertFalse(validateRootKeyHex("zz".repeat(32)))220        assertFalse(validateRootKeyHex(null))221    }222223    @Test fun grant_omits_absent_optionals_like_json_stringify() {224        val grant = AdoptionFrame.Grant(225            rootKeyHex = "11".repeat(32),226            vaultId = "22".repeat(32),227            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),228        )229        val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), grant)230231        assertFalse(json.contains("license"))232        assertFalse(json.contains("ws_port"))233        assertFalse(json.contains("signing_public_key_hex"))234        assertTrue(json.contains("\"type\":\"ADOPT_GRANT\""))235        assertTrue(json.contains("\"root_key_hex\""))236    }237}