1d32590746bc

C-01: authenticate Codex candidate in memory before any write, rollback on storage failure

time
2026-07-29 06:06 UTC · 2 months ago
machine
koh2
agent
claude-opus-4
session
audit-remediation-2026-07-29
parent
12b8bb8814a4
2 files app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+78 −10app/src/test/java/com/peachpasswords/vault/CodexRestoreAtomicityTest.kt+126 −0
app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+78 −10
@@ -113,6 +113,8 @@113113     }114114 115115     suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {116+        var candidateKey: ByteArray? = null117+        var plaintext: ByteArray? = null116118         return try {117119             val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)118120             // Parsed as JSON, not matched with a regex. The wrapper is@@ -135,17 +137,53 @@135137                     BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword136138                 }137139                 ?: return UnlockResult.Error("invalid_codex_blob")138-            if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")140+            // C-01: authenticate the candidate ENTIRELY in memory before touching141+            // storage. VaultCodec.decodeSlot is structural — an Android-shaped142+            // Codex parses under ANY password — so writing slot A first and143+            // unlocking afterwards destroyed the device's existing vault every144+            // time the user mistyped the password.145+            candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))146+            plaintext = decryptSlotABlob(slotA, candidateKey)147+            // LV-1: a Codex printed from a vault sealed before NFC normalization148+            // opens only under the raw password bytes. unlockLocked used to supply149+            // this fallback; authenticating up front here has to keep it, or those150+            // Codexes become unrestorable.151+            if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {152+                VaultCrypto.zero(candidateKey)153+                candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))154+                plaintext = decryptSlotABlob(slotA, candidateKey)155+            }156+            if (plaintext == null) return UnlockResult.WrongPassword157+            val payload = try {158+                VaultCodec.decode(plaintext)159+            } catch (_: Exception) {160+                return UnlockResult.Error("invalid_codex_blob")161+            }162+163+            val priorA = store.loadSlotA()164+            val priorB = store.loadSlotB()139165             // Carry the real slot B across when the Codex has one — it holds a140166             // duress vault, which noise would silently destroy.141-            val slotB = wrapper["b"].asSlotJson()142-            if (slotB != null) {143-                store.writeSlotB(Base64.getEncoder().encodeToString(slotB.toByteArray(Charsets.UTF_8)))144-            } else {145-                writeMatchingNoiseSlotB(slotA)167+            val codexSlotB = wrapper["b"].asSlotJson()168+169+            if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")170+            val committed = runCatching {171+                val wroteB = if (codexSlotB != null) {172+                    store.writeSlotB(Base64.getEncoder().encodeToString(codexSlotB.toByteArray(Charsets.UTF_8)))173+                } else {174+                    writeMatchingNoiseSlotB(slotA)175+                }176+                if (!wroteB) throw IllegalStateException("storage_write_failed")177+                store.stampLocalMutation()178+            }.isSuccess179+            if (!committed) {180+                rollbackRestore(priorA, priorB)181+                return UnlockResult.Error("storage_write_failed")146182             }147-            store.stampLocalMutation()148-            unlockLocked(password)183+184+            val ownedKey = candidateKey!!185+            candidateKey = null // the session owns the key from here on186+            adoptSlotA(ownedKey, payload)149187         } catch (e: Exception) {150188             // A raw kotlinx parse error ("Unexpected JSON token at offset N")151189             // reaching the user says nothing about WHICH json failed — the@@ -155,9 +193,39 @@155193             // no ciphertext or vault content lands in a log.156194             if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))157195             UnlockResult.Error(e.message ?: "restore_failed")196+        } finally {197+            plaintext?.let(VaultCrypto::zero)198+            candidateKey?.let(VaultCrypto::zero)158199         }159200     }160201 202+    /** Slot-A plaintext under [key], or null when the GCM tag fails (wrong password). */203+    private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =204+        runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()205+206+    /**207+     * Undo a partially-committed Codex restore. Every step is attempted208+     * independently so one failure cannot strand the others. With no prior vault209+     * the accurate inverse is having none at all, rather than a half-written210+     * slot A that [hasVault] would report as a real vault.211+     */212+    private fun rollbackRestore(priorA: StoredVault?, priorB: String?) {213+        val failures = mutableListOf<String>()214+        if (priorA == null) {215+            runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }216+        } else {217+            runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }218+                .onFailure { failures += "writeSlotA: $it" }219+            if (priorB != null) {220+                runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }221+                    .onFailure { failures += "writeSlotB: $it" }222+            }223+        }224+        // Log is a throwing stub off-device, and a failed log must never mask225+        // the rollback result the caller is about to report.226+        if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }227+    }228+161229     /** Debug-only: structure of a failed Codex restore, with content masked. */162230     private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {163231         val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)@@ -1437,13 +1505,13 @@14371505         return StoredVault(version = 1, salt = b64(salt), blob = blob)14381506     }14391507 1440-    private fun writeMatchingNoiseSlotB(slotA: StoredVault) {1508+    private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {14411509         // Size the noise so its base64 length matches slot A's blob (MN-1): with the14421510         // plaintext now bucket-padded, an at-rest observer sees two equal-length slots14431511         // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.14441512         val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)14451513         val noise = ByteArray(approxBytes).also(rng::nextBytes)1446-        store.writeSlotB(b64(noise))1514+        return store.writeSlotB(b64(noise))14471515     }14481516 14491517     /** Re-seal slot A from the current in-memory state under the live key. */

view file

app/src/test/java/com/peachpasswords/vault/CodexRestoreAtomicityTest.kt+126 −0
@@ -0,0 +1,126 @@1+package com.peachpasswords.vault2+3+import kotlinx.coroutines.runBlocking4+import kotlinx.serialization.json.JsonNull5+import kotlinx.serialization.json.JsonPrimitive6+import kotlinx.serialization.json.buildJsonObject7+import org.junit.Assert.assertEquals8+import org.junit.Assert.assertTrue9+import org.junit.Test10+import java.util.Base6411+12+/**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+ */18+class CodexRestoreAtomicityTest {19+20+    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+    }51+52+    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+    }58+59+    /** 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+    }69+70+    /** 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+    }79+80+    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+        }86+87+    @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+    }94+95+    @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+    }102+103+    @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+    }114+115+    @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+}

view file