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
↓ peach-android-77e71ef2.tar.gzatlas at this save
files changed3 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 −33app/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,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 {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 /**