77e71ef26cba

M-05: try/finally zero plaintexts/derived keys across persistSlotA, sync, autofill biometric

time
2026-07-29 13:15 UTC · 2 months ago
machine
koh2
agent
claude-opus-4
session
audit-remediation-2026-07-29
parent
142e04d50602
3 files app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+9 −1app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+55 −21app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+55 −33
app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+9 −1
@@ -185,9 +185,17 @@185185             crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),186186             onSuccess = { result ->187187                 val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()188+                // M-05: unlockWithKey copies `key` into the session's derivedKey,189+                // so the caller-owned reference is now redundant. Zero it whether190+                // the unlock succeeds or fails so the unwrapped biometric key191+                // doesn't outlive the auth prompt.188192                 val key = BiometricKeyStore.unwrap(this, used) ?: return@authenticateCrypto onFail()189193                 lifecycleScope.launch {190-                    if (vault.unlockWithKey(key) is UnlockResult.Success) onUnlocked() else onFail()194+                    try {195+                        if (vault.unlockWithKey(key) is UnlockResult.Success) onUnlocked() else onFail()196+                    } finally {197+                        com.peachpasswords.vault.VaultCrypto.zero(key)198+                    }191199                 }192200             },193201             onFail = onFail,

view file

app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+55 −21
@@ -1,6 +1,7 @@11 package com.peachpasswords.sync22 33 import com.peachpasswords.vault.Tombstone4+import com.peachpasswords.vault.VaultCrypto45 import com.peachpasswords.vault.VaultEntry56 import com.peachpasswords.vault.VaultRepository67 import kotlinx.serialization.json.Json@@ -57,7 +58,16 @@5758             val out = LinkedHashMap<String, String>()5859             for (id in entryIds) {5960                 val entry = repo.getEntry(id) ?: continue60-                out[id] = encryptEntry(deriveEntryKey(master, id), entry)61+                // M-05: deriveEntryKey materializes a per-entry 32-byte key that62+                // never leaves this loop; zero it after encrypt so the GC copy63+                // doesn't outlive the ciphertext.64+                var entryKey: ByteArray? = null65+                try {66+                    entryKey = deriveEntryKey(master, id)67+                    out[id] = encryptEntry(entryKey, entry)68+                } finally {69+                    entryKey?.let(VaultCrypto::zero)70+                }6171             }6272             return out6373         } finally {@@ -69,7 +79,16 @@6979         val master = requireMasterKey()7080         val decrypted = try {7181             entries.mapNotNull { (id, envelope) ->72-                val entry = decryptEntryOrNull(deriveEntryKey(master, id), envelope)82+                // M-05: per-entry derived key is used exactly once below; zero it83+                // after the decrypt so it doesn't sit on the heap alongside the84+                // plaintext VaultEntry we're about to commit.85+                var entryKey: ByteArray? = null86+                val entry = try {87+                    entryKey = deriveEntryKey(master, id)88+                    decryptEntryOrNull(entryKey, envelope)89+                } finally {90+                    entryKey?.let(VaultCrypto::zero)91+                }7392                 // The map key is the authority on id — refuse a swapped envelope7493                 // so a ciphertext can't poison a different entry slot.7594                 if (entry != null && entry.id == id) entry else null@@ -252,28 +271,43 @@252271     }253272 254273     private fun encryptEntry(key: ByteArray, entry: VaultEntry): String {255-        val plaintext = entryJson.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)256-        val nonce = ByteArray(12).also(rng::nextBytes)257-        val cipher = Cipher.getInstance("AES/GCM/NoPadding")258-        cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))259-        cipher.updateAAD(ENTRY_AAD)260-        val ct = cipher.doFinal(plaintext)261-        return b64(nonce + ct)262-    }263-264-    private fun decryptEntryOrNull(key: ByteArray, envelopeB64: String): VaultEntry? = try {265-        val bytes = unb64(envelopeB64)266-        if (bytes.size < 12 + 16) null else {267-            val nonce = bytes.copyOfRange(0, 12)268-            val ct = bytes.copyOfRange(12, bytes.size)274+        // M-05: plaintext is the entry JSON (incl. password field). Zero in275+        // finally so the serialized copy doesn't outlive the ciphertext.276+        var plaintext: ByteArray? = null277+        return try {278+            plaintext = entryJson.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)279+            val nonce = ByteArray(12).also(rng::nextBytes)269280             val cipher = Cipher.getInstance("AES/GCM/NoPadding")270-            cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))281+            cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))271282             cipher.updateAAD(ENTRY_AAD)272-            val pt = cipher.doFinal(ct)273-            entryJson.decodeFromString(VaultEntry.serializer(), String(pt, Charsets.UTF_8))283+            val ct = cipher.doFinal(plaintext)284+            b64(nonce + ct)285+        } finally {286+            plaintext?.let(VaultCrypto::zero)274287         }275-    } catch (_: Exception) {276-        null288+    }289+290+    private fun decryptEntryOrNull(key: ByteArray, envelopeB64: String): VaultEntry? {291+        var pt: ByteArray? = null292+        return try {293+            val bytes = unb64(envelopeB64)294+            if (bytes.size < 12 + 16) null else {295+                val nonce = bytes.copyOfRange(0, 12)296+                val ct = bytes.copyOfRange(12, bytes.size)297+                val cipher = Cipher.getInstance("AES/GCM/NoPadding")298+                cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))299+                cipher.updateAAD(ENTRY_AAD)300+                pt = cipher.doFinal(ct)301+                entryJson.decodeFromString(VaultEntry.serializer(), String(pt, Charsets.UTF_8))302+            }303+        } catch (_: Exception) {304+            null305+        } finally {306+            // M-05: pt holds the serialized entry including password until the307+            // decoded VaultEntry above is the only remaining reference. Zero it308+            // so the raw bytes don't outlive the structure they were parsed into.309+            pt?.let(VaultCrypto::zero)310+        }277311     }278312 279313     private companion object {

view file

app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+55 −33
@@ -1594,15 +1594,20 @@15941594         val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null15951595         val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))15961596         val keyBytes = recoveryKey.encoded1597+        var pt: ByteArray? = null15971598         return try {15981599             val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1599-            val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1600+            pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)16001601             hexToRootKey(String(pt, Charsets.US_ASCII))16011602         } catch (_: Exception) {16021603             null16031604         } finally {1605+            // M-05: pt is the lowercase hex of the 32-byte root key — i.e. the1606+            // IKM-equivalent of the vault identity. material+keyBytes were1607+            // already zeroed; pt was leaking.16041608             VaultCrypto.zero(material)16051609             VaultCrypto.zero(keyBytes)1610+            pt?.let(VaultCrypto::zero)16061611         }16071612     }16081613 @@ -1781,9 +1786,16 @@17811786     }17821787 17831788     private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1784-        val plaintext = VaultCodec.encode(currentPayload())1785-        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1786-        return StoredVault(version = 1, salt = b64(salt), blob = blob)1789+        var plaintext = VaultCodec.encode(currentPayload())1790+        return try {1791+            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1792+            StoredVault(version = 1, salt = b64(salt), blob = blob)1793+        } finally {1794+            // M-05: full vault plaintext is allocated here once per createVault.1795+            // Zeroing in finally keeps the transient copy off the heap after the1796+            // caller either succeeds or rolls back via lock().1797+            VaultCrypto.zero(plaintext)1798+        }17871799     }17881800 17891801     private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {@@ -1798,37 +1810,47 @@17981810     /** Re-seal slot A from the current in-memory state under the live key. */17991811     private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {18001812         val key = derivedKey ?: throw IllegalStateException("vault_locked")1801-        // Decoy sessions persist to the decoy slot only — never touch the real slot A.1802-        if (decoyMode) {1803-            val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1804-            val plaintext = VaultCodec.encode(currentPayload())1813+        var plaintext: ByteArray? = null1814+        return try {1815+            // Decoy sessions persist to the decoy slot only — never touch the real slot A.1816+            if (decoyMode) {1817+                val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1818+                plaintext = VaultCodec.encode(currentPayload())1819+                val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1820+                plaintext = null1821+                return store.writeDecoySlot(prior.copy(blob = blob))1822+            }1823+            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1824+            // H-01: captured BEFORE the noise write so a stamp failure can restore1825+            // the prior slot B alongside the prior slot A. Slot B is always1826+            // overwritten by writeMatchingNoiseSlotB below, so a stamp failure1827+            // leaves (new A, new B, old version) on disk — partial commit.1828+            val priorB = store.loadSlotB()1829+            if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1830+            plaintext = VaultCodec.encode(currentPayload())18051831             val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1806-            return store.writeDecoySlot(prior.copy(blob = blob))1832+            plaintext = null1833+            val next = prior.copy(blob = blob)1834+            if (!store.writeSlotA(next)) return false1835+            // Slot A committed — keep slot B size-matched, then advance the version1836+            // for local writes only. Restore both A and B if a later step fails.1837+            // P1-1 (codex): the noise write's boolean AND any thrown exception from1838+            // it must trigger rollback; previously the call sat outside the try.1839+            try {1840+                if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")1841+                if (bumpVersion) store.stampLocalMutation()1842+            } catch (e: Exception) {1843+                store.writeSlotA(prior)1844+                if (priorB != null) runCatching { store.writeSlotB(priorB) }1845+                throw e1846+            }1847+            true1848+        } finally {1849+            // M-05: the full vault plaintext crosses this helper on every save.1850+            // Zeroing in finally covers both the success and the rollback/throw1851+            // paths so a partial commit can't leave a copy in GC.1852+            plaintext?.let(VaultCrypto::zero)18071853         }1808-        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1809-        // H-01: captured BEFORE the noise write so a stamp failure can restore1810-        // the prior slot B alongside the prior slot A. Slot B is always1811-        // overwritten by writeMatchingNoiseSlotB below, so a stamp failure1812-        // leaves (new A, new B, old version) on disk — partial commit.1813-        val priorB = store.loadSlotB()1814-        if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1815-        val plaintext = VaultCodec.encode(currentPayload())1816-        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1817-        val next = prior.copy(blob = blob)1818-        if (!store.writeSlotA(next)) return false1819-        // Slot A committed — keep slot B size-matched, then advance the version1820-        // for local writes only. Restore both A and B if a later step fails.1821-        // P1-1 (codex): the noise write's boolean AND any thrown exception from1822-        // it must trigger rollback; previously the call sat outside the try.1823-        try {1824-            if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")1825-            if (bumpVersion) store.stampLocalMutation()1826-        } catch (e: Exception) {1827-            store.writeSlotA(prior)1828-            if (priorB != null) runCatching { store.writeSlotB(priorB) }1829-            throw e1830-        }1831-        return true18321854     }18331855 18341856     /**

view file