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
↓ peach-android-1d325907.tar.gzatlas at this save
files changed2 files
app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+78 −10app/src/test/java/com/peachpasswords/vault/CodexRestoreAtomicityTest.kt+126 −0app/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. */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+}