VaultSyncManifestEpochRaceTest.kt

7.3 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(): Boolean { slotA = null; slotB = null; version = 0; settings = VaultSettings(); return true }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(99            repo,100            "android-self",101            registry,102            Pending(),103            localCredentials = randomLocalIdentityCredentials("android-self"),104        )105        val manifest = buildSignedDeviceManifest(106            issuerDeviceId = "issuer",107            devices = listOf(108                ManifestDeviceEntry(109                    deviceId = "new-device",110                    deviceName = "New",111                    deviceType = "extension",112                    longTermPublicKeyHex = "aa".repeat(32),113                    signingPublicKeyHex = "bb".repeat(32),114                    relayUrl = "wss://relay.peachpasswords.com",115                    pairedAt = 2L,116                ),117            ),118            signingPrivateKey = issuerSigning.privateKey,119            revoked = listOf(120                DeviceRevocation(121                    deviceId = "old-device",122                    longTermPublicKeyHex = "dd".repeat(32),123                    revokedAt = System.currentTimeMillis(),124                    revokedByDeviceId = "issuer",125                ),126            ),127        )128129        val enteredFence = CountDownLatch(1)130        val releaseFence = CountDownLatch(1)131        try {132            repo.beforeSessionEpochCommit = {133                enteredFence.countDown()134                check(releaseFence.await(5, TimeUnit.SECONDS)) { "manifest did not release" }135            }136            val applyFailure = AtomicReference<Throwable?>()137            val applyDone = CountDownLatch(1)138            Thread {139                try {140                    runBlocking {141                        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })142                    }143                } catch (t: Throwable) {144                    applyFailure.set(t)145                } finally {146                    applyDone.countDown()147                }148            }.start()149            assertTrue(enteredFence.await(5, TimeUnit.SECONDS))150151            val relockDone = CountDownLatch(1)152            Thread {153                repo.lock()154                runBlocking {155                    assertTrue(repo.unlock("correct horse battery staple") is com.peachpasswords.vault.UnlockResult.Success)156                }157                relockDone.countDown()158            }.start()159            assertTrue(relockDone.await(5, TimeUnit.SECONDS))160            releaseFence.countDown()161            assertTrue(applyDone.await(5, TimeUnit.SECONDS))162            assertEquals(null, applyFailure.get())163            assertEquals("the stale epoch must never reach the registry", 0, registry.batchCalls)164            assertTrue(registry.get("old-device") != null)165            assertTrue(registry.get("new-device") == null)166            assertTrue(registry.listRevocations().isEmpty())167        } finally {168            releaseFence.countDown()169            repo.beforeSessionEpochCommit = {}170        }171    }172}