CodexRestoreAtomicityTest.kt

5.9 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        // H-04: codex now carries "a" and "b" as siblings. Match the first63        // balanced `{…}` after `"a":` — earlier regex matched through to the64        // wrapper's last brace and broke when the duress slot was added.65        val openChar = Regex("\"a\"\\s*:\\s*\\{").find(wrapper)!!.range.last66        val open = openChar + 167        var depth = 168        var end = open69        while (end < wrapper.length && depth > 0) {70            when (wrapper[end]) {71                '{' -> depth++72                '}' -> depth--73            }74            if (depth == 0) break75            end++76        }77        val slotA = wrapper.substring(openChar, end + 1)78        val browserWrapper = buildJsonObject {79            put("a", JsonPrimitive(slotA))80            put("b", JsonNull)81        }.toString()82        return Base64.getUrlEncoder().withoutPadding().encodeToString(browserWrapper.toByteArray(Charsets.UTF_8))83    }8485    /** An existing local vault holding one entry, plus its storage. */86    private fun existingVault(): Pair<VaultRepository, FaultStorage> = runBlocking {87        val storage = FaultStorage()88        val repo = VaultRepository(storage)89        repo.createVault("local pass")90        repo.addEntry(VaultEntry(id = "", title = "Local", type = "login", password = "p", createdAt = 0, modifiedAt = 0))91        repo.lock()92        repo to storage93    }9495    private fun assertLocalVaultIntact(repo: VaultRepository, storage: FaultStorage, before: StoredVault?) =96        runBlocking {97            assertEquals("slot A must be untouched", before, storage.slotA)98            assertEquals(UnlockResult.Success, repo.unlock("local pass"))99            assertTrue(repo.getEntries().any { it.title == "Local" })100        }101102    @Test fun wrong_password_on_android_shaped_codex_preserves_local_vault() {103        val blob = sourceCodexBlob()104        val (repo, storage) = existingVault()105        val before = storage.slotA106        runBlocking { assertEquals(UnlockResult.WrongPassword, repo.restoreFromCodex(blob, "not the pass")) }107        assertLocalVaultIntact(repo, storage, before)108    }109110    @Test fun wrong_password_on_browser_shaped_codex_preserves_local_vault() {111        val blob = asBrowserShapedWrapper(sourceCodexBlob())112        val (repo, storage) = existingVault()113        val before = storage.slotA114        runBlocking { assertEquals(UnlockResult.WrongPassword, repo.restoreFromCodex(blob, "not the pass")) }115        assertLocalVaultIntact(repo, storage, before)116    }117118    @Test fun slot_a_write_failure_preserves_local_vault() {119        val blob = sourceCodexBlob()120        val (repo, storage) = existingVault()121        val before = storage.slotA122        storage.failSlotA = true123        runBlocking {124            assertEquals(UnlockResult.Error("storage_write_failed"), repo.restoreFromCodex(blob, "source pass"))125        }126        storage.failSlotA = false127        assertLocalVaultIntact(repo, storage, before)128    }129130    @Test fun later_write_failure_rolls_back_to_local_vault() {131        val blob = sourceCodexBlob()132        val (repo, storage) = existingVault()133        val before = storage.slotA134        storage.failSlotB = true135        runBlocking {136            assertEquals(UnlockResult.Error("storage_write_failed"), repo.restoreFromCodex(blob, "source pass"))137        }138        storage.failSlotB = false139        assertLocalVaultIntact(repo, storage, before)140    }141}