VaultSyncManifestEpochRaceTest.kt

7.2 KB · raw

1package com.peachpasswords.sync23import com.peachpasswords.vault.StoredVault4import com.peachpasswords.vault.CreateVaultResult5import com.peachpasswords.vault.VaultRepository6import com.peachpasswords.vault.VaultSettings7import com.peachpasswords.vault.VaultStorage8import kotlinx.coroutines.runBlocking9import org.junit.Assert.assertEquals10import org.junit.Assert.assertFalse11import org.junit.Assert.assertTrue12import org.junit.Test13import java.util.concurrent.CountDownLatch14import java.util.concurrent.TimeUnit15import java.util.concurrent.atomic.AtomicReference1617/** Regression for a stale signed manifest mutating only a prefix before relock. */18class VaultSyncManifestEpochRaceTest {19    private class Storage : VaultStorage {20        private var slotA: StoredVault? = null21        private var slotB: String? = null22        private var version = 0L23        private var settings = VaultSettings()24        override fun loadSlotA() = slotA25        override fun hasVault() = slotA != null26        override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }27        override fun loadSlotB() = slotB28        override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }29        override fun loadDecoySlot(): StoredVault? = null30        override fun writeDecoySlot(slot: StoredVault): Boolean = true31        override fun hasDecoySlot(): Boolean = false32        override fun clearDecoySlot() = Unit33        override fun version() = version34        override fun stampLocalMutation(): Long { version += 1; return version }35        override fun loadSettings() = settings36        override fun writeSettings(settings: VaultSettings): Boolean { this.settings = settings; return true }37        override fun clearVault() { slotA = null; slotB = null; version = 0; settings = VaultSettings() }38    }3940    private class Registry(initial: List<PairedDevice>) : PairedDeviceRegistry {41        private val devices = initial.associateBy { it.deviceId }.toMutableMap()42        private val revocations = mutableListOf<DeviceRevocation>()43        var batchCalls = 04445        override fun getAll(): List<PairedDevice> = devices.values.toList()46        override fun get(id: String): PairedDevice? = devices[id]47        override fun upsert(device: PairedDevice) { devices[device.deviceId] = device }48        override fun remove(deviceId: String) { devices.remove(deviceId) }49        override fun listRevocations(): List<DeviceRevocation> = revocations.toList()50        override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean {51            batchCalls += 152            batch.removals.forEach(devices::remove)53            batch.upserts.forEach { devices[it.deviceId] = it }54            revocations += batch.incomingRevocations55            return true56        }57    }5859    private class Pending : PendingVouchedRegistry {60        override fun getAll(): List<PendingVouchedDevice> = emptyList()61        override fun upsert(device: PendingVouchedDevice) = Unit62        override fun remove(id: String) = Unit63    }6465    @Test66    fun stale_manifest_is_rejected_as_a_whole_batch_after_lock_and_reunlock() = runBlocking {67        val repo = VaultRepository(Storage())68        assertTrue(repo.createVault("correct horse battery staple") is CreateVaultResult.Success)69        repo.lock()7071        val master = repo.syncMasterKey() // locked, so derive the issuer from a fresh unlock below72        assertEquals(null, master)73        assertTrue(repo.unlock("correct horse battery staple") is com.peachpasswords.vault.UnlockResult.Success)74        val syncMaster = repo.syncMasterKey()!!75        val issuerSigning = deriveSyncSigningKeypair(syncMaster, "issuer")76        val registry = Registry(77            listOf(78                PairedDevice(79                    deviceId = "issuer",80                    deviceName = "Issuer",81                    deviceType = "android",82                    longTermPublicKeyHex = "11".repeat(32),83                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),84                    trustSource = "direct",85                    pairedAt = 1L,86                ),87                PairedDevice(88                    deviceId = "old-device",89                    deviceName = "Old",90                    deviceType = "extension",91                    longTermPublicKeyHex = "dd".repeat(32),92                    signingPublicKeyHex = "ee".repeat(32),93                    trustSource = "direct",94                    pairedAt = 1L,95                ),96            ),97        )98        val adapter = VaultSyncAdapter(repo, "android-self", registry, Pending())99        val manifest = buildSignedDeviceManifest(100            issuerDeviceId = "issuer",101            devices = listOf(102                ManifestDeviceEntry(103                    deviceId = "new-device",104                    deviceName = "New",105                    deviceType = "extension",106                    longTermPublicKeyHex = "aa".repeat(32),107                    signingPublicKeyHex = "bb".repeat(32),108                    relayUrl = "wss://relay.peachpasswords.com",109                    pairedAt = 2L,110                ),111            ),112            signingPrivateKey = issuerSigning.privateKey,113            revoked = listOf(114                DeviceRevocation(115                    deviceId = "old-device",116                    longTermPublicKeyHex = "dd".repeat(32),117                    revokedAt = System.currentTimeMillis(),118                    revokedByDeviceId = "issuer",119                ),120            ),121        )122123        val enteredFence = CountDownLatch(1)124        val releaseFence = CountDownLatch(1)125        try {126            repo.beforeSessionEpochCommit = {127                enteredFence.countDown()128                check(releaseFence.await(5, TimeUnit.SECONDS)) { "manifest did not release" }129            }130            val applyFailure = AtomicReference<Throwable?>()131            val applyDone = CountDownLatch(1)132            Thread {133                try {134                    runBlocking {135                        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })136                    }137                } catch (t: Throwable) {138                    applyFailure.set(t)139                } finally {140                    applyDone.countDown()141                }142            }.start()143            assertTrue(enteredFence.await(5, TimeUnit.SECONDS))144145            val relockDone = CountDownLatch(1)146            Thread {147                repo.lock()148                runBlocking {149                    assertTrue(repo.unlock("correct horse battery staple") is com.peachpasswords.vault.UnlockResult.Success)150                }151                relockDone.countDown()152            }.start()153            assertTrue(relockDone.await(5, TimeUnit.SECONDS))154            releaseFence.countDown()155            assertTrue(applyDone.await(5, TimeUnit.SECONDS))156            assertEquals(null, applyFailure.get())157            assertEquals("the stale epoch must never reach the registry", 0, registry.batchCalls)158            assertTrue(registry.get("old-device") != null)159            assertTrue(registry.get("new-device") == null)160            assertTrue(registry.listRevocations().isEmpty())161        } finally {162            releaseFence.countDown()163            repo.beforeSessionEpochCommit = {}164        }165    }166}