ChirpAdoptTest.kt

12.8 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        private val sasConfirmed: Boolean = true,60    ) : AdoptionReceiverDeps {61        var createCalledWith: String? = null62        var licenseApplied: String? = null63        val upserted = mutableListOf<PairedDevice>()64        val errors = mutableListOf<String>()65        override fun generateDeviceId(): String = "receiver-ephemeral"66        override suspend fun confirmSas(): Boolean = sasConfirmed67        override suspend fun createVaultFromRootKey(rootKeyHex: String): String {68            createCalledWith = rootKeyHex69            if (createThrows) throw IllegalStateException("commit_failed")70            return vaultId ?: error("no vault id")71        }72        override suspend fun deriveLocalIdentity(): LocalChirpIdentity = derived ?: error("no identity")73        override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }74        override suspend fun applyLicense(license: String) { licenseApplied = license }75        override fun onError(error: String) { errors += error }76    }7778    private class RecorderSource(79        override val sourceIdentity: LocalChirpIdentity,80        private val rootKeyHex: String,81        private val vaultId: String,82        private val license: String? = null,83        private val consent: Boolean = true,84    ) : AdoptionSourceDeps {85        val upserted = mutableListOf<PairedDevice>()86        var consentAsked = false87        var rootRequested = false88        override suspend fun rootKeyHex(): String {89            rootRequested = true90            return rootKeyHex91        }92        override suspend fun vaultId(): String = vaultId93        override suspend fun license(): String? = license94        override suspend fun onConsent(request: AdoptionFrame.Request): Boolean {95            consentAsked = true96            return consent97        }98        override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }99    }100101    @Test fun completes_request_grant_identity_round_trip() = runBlocking {102        val (receiverCh, sourceCh) = memoryPair()103        val rootKeyHex = "11".repeat(32)104        val vaultId = vaultIdFor(rootKeyHex)105        val receiverIdentity = identity("receiver").copy(deviceType = DeviceType.ANDROID)106        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, derived = receiverIdentity)107        val source = RecorderSource(identity("source"), rootKeyHex, vaultId, license = "PPK-test")108109        val recvJob = async { requestAdoption(receiverCh, receiver) { 111L } }110        val grantJob = async {111            val request = receiveFrame(sourceCh) as AdoptionFrame.Request112            grantAdoption(sourceCh, request, source) { 222L }113        }114        recvJob.await()115        grantJob.await()116117        assertEquals(rootKeyHex, receiver.createCalledWith)118        assertEquals("PPK-test", receiver.licenseApplied)119        assertEquals("source-id", receiver.upserted.single().deviceId)120        assertEquals("receiver-id", source.upserted.single().deviceId)121        assertEquals("android", source.upserted.single().deviceType)122    }123124    /**125     * The constraint from `PEACH_RELAY_AND_DEVICEID_FIXES.md` Part 2: the label126     * on ADOPT_REQUEST is generated before any key exists, so persisting it as127     * an identity would key trust on a value with no continuity between128     * attempts. What lands in the store must come from ADOPT_IDENTITY.129     */130    @Test fun request_label_is_never_what_gets_persisted() = runBlocking {131        val (receiverCh, sourceCh) = memoryPair()132        val rootKeyHex = "55".repeat(32)133        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))134        // Deliberately unlike the identity the receiver will present.135        val request = AdoptionFrame.Request("ephemeral-label-not-an-identity", "Receiver", "extension")136137        val grantJob = async { grantAdoption(sourceCh, request, source) { 0L } }138        sendFrame(receiverCh, AdoptionFrame.Confirm(true))139        receiveFrame(receiverCh) // the grant140        sendFrame(141            receiverCh,142            AdoptionFrame.Identity(143                deviceId = "receiver-id",144                deviceName = "Receiver",145                deviceType = "extension",146                longTermPublicKeyHex = "aa".repeat(32),147                signingPublicKeyHex = "bb".repeat(32),148            ),149        )150        grantJob.await()151152        val persisted = source.upserted.single()153        assertEquals("receiver-id", persisted.deviceId)154        assertEquals("aa".repeat(32), persisted.longTermPublicKeyHex)155        assertTrue(156            "the ephemeral request label must not reach the paired-device store",157            source.upserted.none { it.deviceId == "ephemeral-label-not-an-identity" },158        )159    }160161    @Test fun request_carries_receiver_device_type() = runBlocking {162        val (receiverCh, sourceCh) = memoryPair()163        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)164        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }165        val request = receiveFrame(sourceCh) as AdoptionFrame.Request166        recvJob.cancel()167168        assertEquals("android", request.deviceType)169        assertEquals("receiver-ephemeral", request.sessionRequestId)170        assertEquals("Receiver", request.deviceName)171    }172173    @Test fun denied_consent_grants_nothing() = runBlocking {174        val (receiverCh, sourceCh) = memoryPair()175        val rootKeyHex = "33".repeat(32)176        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultIdFor(rootKeyHex))177        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex), consent = false)178179        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }180        val request = receiveFrame(sourceCh) as AdoptionFrame.Request181        val granted = grantAdoption(sourceCh, request, source) { 0L }182        recvJob.cancel()183184        assertNull(granted)185        assertTrue(source.consentAsked)186        assertFalse(source.rootRequested)187        assertTrue(source.upserted.isEmpty())188        assertNull(receiver.createCalledWith)189    }190191    @Test fun receiver_sas_rejection_grants_no_root_key() = runBlocking {192        val (receiverCh, sourceCh) = memoryPair()193        val rootKeyHex = "66".repeat(32)194        val receiver = RecorderReceiver(195            "Receiver",196            DeviceType.ANDROID,197            vaultIdFor(rootKeyHex),198            sasConfirmed = false,199        )200        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))201202        val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }203        val request = receiveFrame(sourceCh) as AdoptionFrame.Request204        val sourceJob = async { grantAdoption(sourceCh, request, source) { 0L } }205206        assertNull(receiverJob.await())207        assertNull(sourceJob.await())208        assertFalse(source.rootRequested)209        assertNull(receiver.createCalledWith)210        assertEquals(listOf("ADOPT_SAS_REJECTED"), receiver.errors)211    }212213    @Test fun invalid_root_key_hex_aborts_as_unexpected_frame() = runBlocking {214        val (receiverCh, sourceCh) = memoryPair()215        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)216        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }217        receiveFrame(sourceCh)218        sendFrame(sourceCh, AdoptionFrame.Grant(219            rootKeyHex = "bad",220            vaultId = "00".repeat(32),221            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),222        ))223        recvJob.await()224225        assertEquals(listOf("ADOPT_UNEXPECTED_FRAME"), receiver.errors)226        assertNull(receiver.createCalledWith)227    }228229    @Test fun vault_id_mismatch_aborts_before_vault_creation() = runBlocking {230        val (receiverCh, sourceCh) = memoryPair()231        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)232        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }233        receiveFrame(sourceCh)234        sendFrame(sourceCh, AdoptionFrame.Grant(235            rootKeyHex = "22".repeat(32),236            vaultId = "00".repeat(32),237            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),238        ))239        recvJob.await()240241        assertEquals(listOf("ADOPT_VAULT_ID_MISMATCH"), receiver.errors)242        assertNull(receiver.createCalledWith)243    }244245    @Test fun source_waits_for_identity_before_persisting_receiver() = runBlocking {246        val (receiverCh, sourceCh) = memoryPair()247        val rootKeyHex = "33".repeat(32)248        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))249        val request = AdoptionFrame.Request("receiver-ephemeral", "Receiver", "extension")250251        sendFrame(receiverCh, AdoptionFrame.Confirm(true))252        val result = withTimeoutOrNull(150) {253            // No receiver answers the ADOPT_IDENTITY read, so grant must hang, not persist.254            grantAdoption(sourceCh, request, source) { 0L }255        }256257        assertNull(result)258        assertTrue(source.upserted.isEmpty())259    }260261    @Test fun does_not_apply_license_when_vault_creation_fails() = runBlocking {262        val (receiverCh, sourceCh) = memoryPair()263        val rootKeyHex = "44".repeat(32)264        val vaultId = vaultIdFor(rootKeyHex)265        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, createThrows = true)266        val recvJob = async {267            runCatching { requestAdoption(receiverCh, receiver) { 0L } }268        }269        receiveFrame(sourceCh)270        sendFrame(sourceCh, AdoptionFrame.Grant(271            rootKeyHex = rootKeyHex,272            vaultId = vaultId,273            license = "PPK-test",274            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),275        ))276        val outcome = recvJob.await()277278        assertTrue(outcome.isFailure)279        assertNull(receiver.licenseApplied)280    }281282    @Test fun validator_rejects_short_and_uppercase_root_keys() {283        assertTrue(validateRootKeyHex("ab".repeat(32)))284        assertFalse(validateRootKeyHex("ab".repeat(31)))285        assertFalse(validateRootKeyHex("AB".repeat(32)))286        assertFalse(validateRootKeyHex("zz".repeat(32)))287        assertFalse(validateRootKeyHex(null))288    }289290    @Test fun grant_omits_absent_optionals_like_json_stringify() {291        val grant = AdoptionFrame.Grant(292            rootKeyHex = "11".repeat(32),293            vaultId = "22".repeat(32),294            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),295        )296        val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), grant)297298        assertFalse(json.contains("license"))299        assertFalse(json.contains("ws_port"))300        assertFalse(json.contains("signing_public_key_hex"))301        assertTrue(json.contains("\"type\":\"ADOPT_GRANT\""))302        assertTrue(json.contains("\"root_key_hex\""))303    }304}