CodexRestoreAtomicityTest.kt

5.4 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.runBlocking4import kotlinx.serialization.json.JsonNull5import kotlinx.serialization.json.JsonPrimitive6import kotlinx.serialization.json.buildJsonObject7import org.junit.Assert.assertEquals8import org.junit.Assert.assertTrue9import org.junit.Test10import java.util.Base641112/**13 * C-01: a Codex restore must not touch the device's vault until the candidate14 * has been decrypted and decoded in memory, and must roll back if any write in15 * the commit fails. An Android-shaped Codex parses structurally under ANY16 * password, so the old write-then-unlock order wiped the local vault on a typo.17 */18class CodexRestoreAtomicityTest {1920    private class FaultStorage : VaultStorage {21        var slotA: StoredVault? = null22        var slotB: String? = null23        var failSlotA = false24        var failSlotB = false25        private var ver: Long = 026        private var settings = VaultSettings()27        override fun loadSlotA() = slotA28        override fun hasVault() = slotA != null29        override fun writeSlotA(slot: StoredVault): Boolean {30            if (failSlotA) return false31            slotA = slot32            return true33        }34        override fun loadSlotB() = slotB35        override fun writeSlotB(blobBase64: String): Boolean {36            if (failSlotB) return false37            slotB = blobBase6438            return true39        }40        var decoy: StoredVault? = null41        override fun loadDecoySlot() = decoy42        override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }43        override fun hasDecoySlot() = decoy != null44        override fun clearDecoySlot() { decoy = null }45        override fun version() = ver46        override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }47        override fun loadSettings() = settings48        override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }49        override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() }50    }5152    private fun sourceCodexBlob(): String = runBlocking {53        val src = VaultRepository(FaultStorage())54        src.createVault("source pass")55        src.addEntry(VaultEntry(id = "", title = "Incoming", type = "login", password = "p", createdAt = 0, modifiedAt = 0))56        src.codexVaultBlobBase64()!!57    }5859    /** Re-wrap an Android blob the way the browser writes it: slot as a JSON string. */60    private fun asBrowserShapedWrapper(androidBlob: String): String {61        val wrapper = String(Base64.getUrlDecoder().decode(androidBlob), Charsets.UTF_8)62        val slotA = Regex("\"a\"\\s*:\\s*(\\{.*\\})\\s*\\}\\s*$").find(wrapper)!!.groupValues[1]63        val browserWrapper = buildJsonObject {64            put("a", JsonPrimitive(slotA))65            put("b", JsonNull)66        }.toString()67        return Base64.getUrlEncoder().withoutPadding().encodeToString(browserWrapper.toByteArray(Charsets.UTF_8))68    }6970    /** An existing local vault holding one entry, plus its storage. */71    private fun existingVault(): Pair<VaultRepository, FaultStorage> = runBlocking {72        val storage = FaultStorage()73        val repo = VaultRepository(storage)74        repo.createVault("local pass")75        repo.addEntry(VaultEntry(id = "", title = "Local", type = "login", password = "p", createdAt = 0, modifiedAt = 0))76        repo.lock()77        repo to storage78    }7980    private fun assertLocalVaultIntact(repo: VaultRepository, storage: FaultStorage, before: StoredVault?) =81        runBlocking {82            assertEquals("slot A must be untouched", before, storage.slotA)83            assertEquals(UnlockResult.Success, repo.unlock("local pass"))84            assertTrue(repo.getEntries().any { it.title == "Local" })85        }8687    @Test fun wrong_password_on_android_shaped_codex_preserves_local_vault() {88        val blob = sourceCodexBlob()89        val (repo, storage) = existingVault()90        val before = storage.slotA91        runBlocking { assertEquals(UnlockResult.WrongPassword, repo.restoreFromCodex(blob, "not the pass")) }92        assertLocalVaultIntact(repo, storage, before)93    }9495    @Test fun wrong_password_on_browser_shaped_codex_preserves_local_vault() {96        val blob = asBrowserShapedWrapper(sourceCodexBlob())97        val (repo, storage) = existingVault()98        val before = storage.slotA99        runBlocking { assertEquals(UnlockResult.WrongPassword, repo.restoreFromCodex(blob, "not the pass")) }100        assertLocalVaultIntact(repo, storage, before)101    }102103    @Test fun slot_a_write_failure_preserves_local_vault() {104        val blob = sourceCodexBlob()105        val (repo, storage) = existingVault()106        val before = storage.slotA107        storage.failSlotA = true108        runBlocking {109            assertEquals(UnlockResult.Error("storage_write_failed"), repo.restoreFromCodex(blob, "source pass"))110        }111        storage.failSlotA = false112        assertLocalVaultIntact(repo, storage, before)113    }114115    @Test fun later_write_failure_rolls_back_to_local_vault() {116        val blob = sourceCodexBlob()117        val (repo, storage) = existingVault()118        val before = storage.slotA119        storage.failSlotB = true120        runBlocking {121            assertEquals(UnlockResult.Error("storage_write_failed"), repo.restoreFromCodex(blob, "source pass"))122        }123        storage.failSlotB = false124        assertLocalVaultIntact(repo, storage, before)125    }126}