ChirpAdoptTest.kt

11.5 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    /**119     * The constraint from `PEACH_RELAY_AND_DEVICEID_FIXES.md` Part 2: the label120     * on ADOPT_REQUEST is generated before any key exists, so persisting it as121     * an identity would key trust on a value with no continuity between122     * attempts. What lands in the store must come from ADOPT_IDENTITY.123     */124    @Test fun request_label_is_never_what_gets_persisted() = runBlocking {125        val (receiverCh, sourceCh) = memoryPair()126        val rootKeyHex = "55".repeat(32)127        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))128        // Deliberately unlike the identity the receiver will present.129        val request = AdoptionFrame.Request("ephemeral-label-not-an-identity", "Receiver", "extension")130131        val grantJob = async { grantAdoption(sourceCh, request, source) { 0L } }132        receiveFrame(receiverCh) // the grant133        sendFrame(134            receiverCh,135            AdoptionFrame.Identity(136                deviceId = "receiver-id",137                deviceName = "Receiver",138                deviceType = "extension",139                longTermPublicKeyHex = "aa".repeat(32),140                signingPublicKeyHex = "bb".repeat(32),141            ),142        )143        grantJob.await()144145        val persisted = source.upserted.single()146        assertEquals("receiver-id", persisted.deviceId)147        assertEquals("aa".repeat(32), persisted.longTermPublicKeyHex)148        assertTrue(149            "the ephemeral request label must not reach the paired-device store",150            source.upserted.none { it.deviceId == "ephemeral-label-not-an-identity" },151        )152    }153154    @Test fun request_carries_receiver_device_type() = runBlocking {155        val (receiverCh, sourceCh) = memoryPair()156        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)157        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }158        val request = receiveFrame(sourceCh) as AdoptionFrame.Request159        recvJob.cancel()160161        assertEquals("android", request.deviceType)162        assertEquals("receiver-ephemeral", request.sessionRequestId)163        assertEquals("Receiver", request.deviceName)164    }165166    @Test fun denied_consent_grants_nothing() = runBlocking {167        val (receiverCh, sourceCh) = memoryPair()168        val rootKeyHex = "33".repeat(32)169        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultIdFor(rootKeyHex))170        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex), consent = false)171172        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }173        val request = receiveFrame(sourceCh) as AdoptionFrame.Request174        val granted = grantAdoption(sourceCh, request, source) { 0L }175        recvJob.cancel()176177        assertNull(granted)178        assertTrue(source.consentAsked)179        assertTrue(source.upserted.isEmpty())180        assertNull(receiver.createCalledWith)181    }182183    @Test fun invalid_root_key_hex_aborts_as_unexpected_frame() = runBlocking {184        val (receiverCh, sourceCh) = memoryPair()185        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)186        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }187        receiveFrame(sourceCh)188        sendFrame(sourceCh, AdoptionFrame.Grant(189            rootKeyHex = "bad",190            vaultId = "00".repeat(32),191            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),192        ))193        recvJob.await()194195        assertEquals(listOf("ADOPT_UNEXPECTED_FRAME"), receiver.errors)196        assertNull(receiver.createCalledWith)197    }198199    @Test fun vault_id_mismatch_aborts_before_vault_creation() = runBlocking {200        val (receiverCh, sourceCh) = memoryPair()201        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)202        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }203        receiveFrame(sourceCh)204        sendFrame(sourceCh, AdoptionFrame.Grant(205            rootKeyHex = "22".repeat(32),206            vaultId = "00".repeat(32),207            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),208        ))209        recvJob.await()210211        assertEquals(listOf("ADOPT_VAULT_ID_MISMATCH"), receiver.errors)212        assertNull(receiver.createCalledWith)213    }214215    @Test fun source_waits_for_identity_before_persisting_receiver() = runBlocking {216        val (_, sourceCh) = memoryPair()217        val rootKeyHex = "33".repeat(32)218        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))219        val request = AdoptionFrame.Request("receiver-ephemeral", "Receiver", "extension")220221        val result = withTimeoutOrNull(150) {222            // No receiver answers the ADOPT_IDENTITY read, so grant must hang, not persist.223            grantAdoption(sourceCh, request, source) { 0L }224        }225226        assertNull(result)227        assertTrue(source.upserted.isEmpty())228    }229230    @Test fun does_not_apply_license_when_vault_creation_fails() = runBlocking {231        val (receiverCh, sourceCh) = memoryPair()232        val rootKeyHex = "44".repeat(32)233        val vaultId = vaultIdFor(rootKeyHex)234        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, createThrows = true)235        val recvJob = async {236            runCatching { requestAdoption(receiverCh, receiver) { 0L } }237        }238        receiveFrame(sourceCh)239        sendFrame(sourceCh, AdoptionFrame.Grant(240            rootKeyHex = rootKeyHex,241            vaultId = vaultId,242            license = "PPK-test",243            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),244        ))245        val outcome = recvJob.await()246247        assertTrue(outcome.isFailure)248        assertNull(receiver.licenseApplied)249    }250251    @Test fun validator_rejects_short_and_uppercase_root_keys() {252        assertTrue(validateRootKeyHex("ab".repeat(32)))253        assertFalse(validateRootKeyHex("ab".repeat(31)))254        assertFalse(validateRootKeyHex("AB".repeat(32)))255        assertFalse(validateRootKeyHex("zz".repeat(32)))256        assertFalse(validateRootKeyHex(null))257    }258259    @Test fun grant_omits_absent_optionals_like_json_stringify() {260        val grant = AdoptionFrame.Grant(261            rootKeyHex = "11".repeat(32),262            vaultId = "22".repeat(32),263            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),264        )265        val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), grant)266267        assertFalse(json.contains("license"))268        assertFalse(json.contains("ws_port"))269        assertFalse(json.contains("signing_public_key_hex"))270        assertTrue(json.contains("\"type\":\"ADOPT_GRANT\""))271        assertTrue(json.contains("\"root_key_hex\""))272    }273}