VaultAdoptTest.kt

13.1 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 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_grant_identity_round_trip() = 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        grantJob.await()156157        val persisted = source.upserted.single()158        assertEquals("receiver-id", persisted.deviceId)159        assertEquals("aa".repeat(32), persisted.longTermPublicKeyHex)160        assertTrue(161            "the ephemeral request label must not reach the paired-device store",162            source.upserted.none { it.deviceId == "ephemeral-label-not-an-identity" },163        )164    }165166    @Test fun request_carries_receiver_device_type() = runBlocking {167        val (receiverCh, sourceCh) = memoryPair()168        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)169        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }170        val request = receiveFrame(sourceCh) as AdoptionFrame.Request171        recvJob.cancel()172173        assertEquals("android", request.deviceType)174        assertEquals("receiver-ephemeral", request.sessionRequestId)175        assertEquals("Receiver", request.deviceName)176    }177178    @Test fun denied_consent_grants_nothing() = runBlocking {179        val (receiverCh, sourceCh) = memoryPair()180        val rootKeyHex = "33".repeat(32)181        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultIdFor(rootKeyHex))182        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex), consent = false)183184        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }185        val request = receiveFrame(sourceCh) as AdoptionFrame.Request186        val granted = grantAdoption(sourceCh, request, source) { 0L }187        recvJob.cancel()188189        assertNull(granted)190        assertTrue(source.consentAsked)191        assertFalse(source.rootRequested)192        assertTrue(source.upserted.isEmpty())193        assertNull(receiver.createCalledWith)194    }195196    @Test fun receiver_sas_rejection_grants_no_root_key() = runBlocking {197        val (receiverCh, sourceCh) = memoryPair()198        val rootKeyHex = "66".repeat(32)199        val receiver = RecorderReceiver(200            "Receiver",201            DeviceType.ANDROID,202            vaultIdFor(rootKeyHex),203            sasConfirmed = false,204        )205        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))206207        val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }208        val request = receiveFrame(sourceCh) as AdoptionFrame.Request209        val sourceJob = async { grantAdoption(sourceCh, request, source) { 0L } }210211        assertNull(receiverJob.await())212        assertNull(sourceJob.await())213        assertFalse(source.rootRequested)214        assertNull(receiver.createCalledWith)215        assertTrue(receiver.revocationsCleared.isEmpty())216        assertEquals(listOf("ADOPT_SAS_REJECTED"), receiver.errors)217    }218219    @Test fun invalid_root_key_hex_aborts_as_unexpected_frame() = runBlocking {220        val (receiverCh, sourceCh) = memoryPair()221        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)222        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }223        receiveFrame(sourceCh)224        sendFrame(sourceCh, AdoptionFrame.Grant(225            rootKeyHex = "bad",226            vaultId = "00".repeat(32),227            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),228        ))229        recvJob.await()230231        assertEquals(listOf("ADOPT_UNEXPECTED_FRAME"), receiver.errors)232        assertNull(receiver.createCalledWith)233    }234235    @Test fun vault_id_mismatch_aborts_before_vault_creation() = runBlocking {236        val (receiverCh, sourceCh) = memoryPair()237        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)238        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }239        receiveFrame(sourceCh)240        sendFrame(sourceCh, AdoptionFrame.Grant(241            rootKeyHex = "22".repeat(32),242            vaultId = "00".repeat(32),243            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),244        ))245        recvJob.await()246247        assertEquals(listOf("ADOPT_VAULT_ID_MISMATCH"), receiver.errors)248        assertNull(receiver.createCalledWith)249    }250251    @Test fun source_waits_for_identity_before_persisting_receiver() = runBlocking {252        val (receiverCh, sourceCh) = memoryPair()253        val rootKeyHex = "33".repeat(32)254        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))255        val request = AdoptionFrame.Request("receiver-ephemeral", "Receiver", "extension")256257        sendFrame(receiverCh, AdoptionFrame.Confirm(true))258        val result = withTimeoutOrNull(150) {259            // No receiver answers the ADOPT_IDENTITY read, so grant must hang, not persist.260            grantAdoption(sourceCh, request, source) { 0L }261        }262263        assertNull(result)264        assertTrue(source.upserted.isEmpty())265    }266267    @Test fun does_not_apply_license_when_vault_creation_fails() = runBlocking {268        val (receiverCh, sourceCh) = memoryPair()269        val rootKeyHex = "44".repeat(32)270        val vaultId = vaultIdFor(rootKeyHex)271        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, createThrows = true)272        val recvJob = async {273            runCatching { requestAdoption(receiverCh, receiver) { 0L } }274        }275        receiveFrame(sourceCh)276        sendFrame(sourceCh, AdoptionFrame.Grant(277            rootKeyHex = rootKeyHex,278            vaultId = vaultId,279            license = "PPK-test",280            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),281        ))282        val outcome = recvJob.await()283284        assertTrue(outcome.isFailure)285        assertNull(receiver.licenseApplied)286    }287288    @Test fun validator_rejects_short_and_uppercase_root_keys() {289        assertTrue(validateRootKeyHex("ab".repeat(32)))290        assertFalse(validateRootKeyHex("ab".repeat(31)))291        assertFalse(validateRootKeyHex("AB".repeat(32)))292        assertFalse(validateRootKeyHex("zz".repeat(32)))293        assertFalse(validateRootKeyHex(null))294    }295296    @Test fun grant_omits_absent_optionals_like_json_stringify() {297        val grant = AdoptionFrame.Grant(298            rootKeyHex = "11".repeat(32),299            vaultId = "22".repeat(32),300            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),301        )302        val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), grant)303304        assertFalse(json.contains("license"))305        assertFalse(json.contains("ws_port"))306        assertFalse(json.contains("signing_public_key_hex"))307        assertTrue(json.contains("\"type\":\"ADOPT_GRANT\""))308        assertTrue(json.contains("\"root_key_hex\""))309    }310}