c7780c7fe52a

feat: Add Keystore-backed locked sync spool (v2.14.14)

time
2026-08-11 17:19 UTC · 2 months ago
machine
koh2
parent
5979437f02d7
14 files VERSION+1 −1app/src/main/java/com/peachpasswords/PeachApplication.kt+9 −6app/src/main/java/com/peachpasswords/sync/LockedSyncSpoolStore.kt+204 −0app/src/main/java/com/peachpasswords/sync/PeachSyncService.kt+12 −9app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt+6 −3app/src/main/java/com/peachpasswords/sync/SyncFreshStartResetter.kt+4 −0app/src/main/java/com/peachpasswords/sync/SyncLimits.kt+4 −0app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+54 −47app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+14 −0app/src/test/java/com/peachpasswords/sync/CloudRelayReconnectListenerTest.kt+1 −1app/src/test/java/com/peachpasswords/sync/SyncCoordinatorTest.kt+5 −1app/src/test/java/com/peachpasswords/sync/SyncFreshStartResetterTest.kt+2 −0app/src/test/java/com/peachpasswords/vault/SessionEpochRaceTest.kt+6 −0app/src/test/java/com/peachpasswords/vault/VaultRepositoryTest.kt+25 −0
VERSION+1 −1
@@ -1 +1 @@1-2.14.131+2.14.14

view file

app/src/main/java/com/peachpasswords/PeachApplication.kt+9 −6
@@ -8,6 +8,8 @@88 import androidx.work.Configuration99 import com.peachpasswords.sync.BackgroundSyncPreference1010 import com.peachpasswords.sync.ForegroundSyncCoordinator11+import com.peachpasswords.sync.LockedSyncSpoolHooks12+import com.peachpasswords.sync.LockedSyncSpoolStore1113 import com.peachpasswords.updates.UpdateCheckWorker1214 import com.peachpasswords.vault.VaultLockTimer1315 import com.peachpasswords.vault.VaultRepository@@ -20,6 +22,7 @@2022     @Inject lateinit var lockTimer: VaultLockTimer2123     @Inject lateinit var foregroundSync: ForegroundSyncCoordinator2224     @Inject lateinit var backgroundSync: BackgroundSyncPreference25+    @Inject lateinit var lockedSyncSpool: LockedSyncSpoolStore2326     @Inject lateinit var workerFactory: HiltWorkerFactory2427 2528     override val workManagerConfiguration: Configuration@@ -27,6 +30,7 @@2730 2831     override fun onCreate() {2932         super.onCreate()33+        LockedSyncSpoolHooks.refresh = lockedSyncSpool::refresh3034 3135         // Update checks: once a day in the background, plus one now — process3236         // start is the honest "app open". Neither ever downloads or installs;@@ -49,12 +53,11 @@4953                 // VaultRepository.expectTransientBackground().5054                 if (vault.consumeTransientBackgroundSuppression()) return5155                 if (backgroundSync.enabled.value && vault.isUnlocked.value) {52-                    // Sync-retained mode: the vault stays unlocked in memory so53-                    // PeachSyncService can complete sessions while backgrounded.54-                    // The UI gate shows the lock screen on resume. The auto-lock55-                    // timer keeps running as a safety net — if it fires, it does56-                    // a full lock and sync stops.57-                    vault.lockUiGate()56+                    // Cache only opaque encrypted sync material, then perform a57+                    // real lock. The service keeps transport authority, not the58+                    // vault root or plaintext entries.59+                    runCatching { lockedSyncSpool.refresh(vault) }60+                    vault.lock()5861                 } else {5962                     vault.lock()6063                     lockTimer.cancel()

view file

app/src/main/java/com/peachpasswords/sync/LockedSyncSpoolStore.kt+204 −0
@@ -0,0 +1,204 @@1+package com.peachpasswords.sync2+3+import android.content.Context4+import androidx.security.crypto.EncryptedSharedPreferences5+import androidx.security.crypto.MasterKey6+import com.peachpasswords.vault.VaultCrypto7+import com.peachpasswords.vault.VaultRepository8+import dagger.hilt.android.qualifiers.ApplicationContext9+import kotlinx.serialization.Serializable10+import kotlinx.serialization.json.Json11+import javax.inject.Inject12+import javax.inject.Singleton13+14+@Serializable15+internal data class LockedSyncCachedEntry(val modifiedAt: Long, val ciphertext: String)16+17+@Serializable18+internal data class LockedSyncSpool(19+    val version: Int = 1,20+    val vaultId: String,21+    val deviceId: String,22+    val deviceName: String,23+    val localVersion: Long,24+    val longTermPublicKeyHex: String,25+    val longTermPrivateKeyHex: String,26+    val signingPublicKeyHex: String,27+    val entries: Map<String, LockedSyncCachedEntry>,28+    val tombstones: Map<String, Long>,29+    val inboxEntries: Map<String, LockedSyncCachedEntry> = emptyMap(),30+    val inboxTombstones: Map<String, Long> = emptyMap(),31+)32+33+/** A Keystore-encrypted cache of opaque entry envelopes and transport identity. */34+@Singleton35+class LockedSyncSpoolStore @Inject constructor(36+    @ApplicationContext private val context: Context,37+) {38+    private val masterKey = MasterKey.Builder(context)39+        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)40+        .build()41+    private val prefs = EncryptedSharedPreferences.create(42+        context,43+        PREFS_FILE,44+        masterKey,45+        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,46+        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,47+    )48+49+    @Synchronized50+    internal fun refresh(repo: VaultRepository) {51+        val snapshot = repo.syncMasterKeySnapshot() ?: return52+        val root = snapshot.key53+        val deviceId = getOrCreateDeviceId(context)54+        val keys = deriveSyncIdentityKeypair(root, deviceId)55+        try {56+            val prior = read()57+            val entries = repo.getEntries().associate { entry ->58+                entry.id to LockedSyncCachedEntry(59+                    entry.modifiedAt,60+                    VaultSyncAdapter.encryptEntryWithMasterKey(root, entry),61+                )62+            }63+            if (!repo.isSessionEpochCurrent(snapshot.epoch)) return64+            val signing = deriveSyncSigningKeypair(root, deviceId)65+            write(66+                LockedSyncSpool(67+                    vaultId = computeVaultId(root),68+                    deviceId = deviceId,69+                    deviceName = getDeviceName(context),70+                    localVersion = repo.getVaultVersion(),71+                    longTermPublicKeyHex = pubkeyToHex(keys.publicKey),72+                    longTermPrivateKeyHex = pubkeyToHex(keys.secretKey),73+                    signingPublicKeyHex = pubkeyToHex(signing.publicKeyRaw),74+                    entries = entries,75+                    tombstones = repo.getTombstones().associate { it.id to it.deletedAt },76+                    inboxEntries = prior?.inboxEntries ?: emptyMap(),77+                    inboxTombstones = prior?.inboxTombstones ?: emptyMap(),78+                ),79+            )80+        } finally {81+            VaultCrypto.zero(root)82+            VaultCrypto.zero(keys.secretKey)83+        }84+    }85+86+    internal fun identity(): Pair<SyncIdentityKeypair, LocalDeviceIdentity>? {87+        val state = read() ?: return null88+        val privateKey = runCatching { hexToPubkey(state.longTermPrivateKeyHex) }.getOrNull() ?: return null89+        val publicKey = runCatching { hexToPubkey(state.longTermPublicKeyHex) }.getOrNull() ?: return null90+        return SyncIdentityKeypair(publicKey, privateKey) to LocalDeviceIdentity(91+            deviceId = state.deviceId,92+            deviceName = state.deviceName,93+            deviceType = DeviceType.ANDROID,94+            longTermPublicKeyHex = state.longTermPublicKeyHex,95+            signingPublicKeyHex = state.signingPublicKeyHex,96+            relayUrl = RelaySettingsStore.readRelayUrl(context),97+        )98+    }99+100+    internal fun adapter(): SyncVaultAdapter? = read()?.let { LockedSyncVaultAdapter(this, it.vaultId) }101+102+    internal suspend fun drainInto(repo: VaultRepository) {103+        val staged = read() ?: return104+        if (staged.inboxEntries.isNotEmpty() || staged.inboxTombstones.isNotEmpty()) {105+            val live = VaultSyncAdapter(repo, staged.deviceId)106+            live.applyEntries(107+                staged.inboxEntries.mapValues { it.value.ciphertext },108+                staged.inboxEntries.mapValues { it.value.modifiedAt },109+            )110+            live.applyTombstones(staged.inboxTombstones)111+            synchronized(this) {112+                val current = read() ?: return@synchronized113+                write(114+                    current.copy(115+                        inboxEntries = current.inboxEntries.filter { (id, value) ->116+                            staged.inboxEntries[id]?.ciphertext != value.ciphertext117+                        },118+                        inboxTombstones = current.inboxTombstones.filter { (id, value) ->119+                            staged.inboxTombstones[id] != value120+                        },121+                    ),122+                )123+            }124+        }125+        refresh(repo)126+    }127+128+    @Synchronized129+    internal fun stageEntries(vaultId: String, entries: Map<String, String>, manifest: Map<String, Long>) {130+        val current = read()?.takeIf { it.vaultId == vaultId } ?: return131+        require(entries.size <= SyncLimits.MAX_ENTRY_COUNT) { "sync_entry_count_exceeded" }132+        val now = System.currentTimeMillis()133+        val inbox = current.inboxEntries.toMutableMap()134+        entries.forEach { (id, ciphertext) ->135+            val claimed = manifest[id] ?: return@forEach136+            if (claimed <= 0L || ciphertext.length > SyncLimits.MAX_ENTRY_ENVELOPE_BYTES) return@forEach137+            val modifiedAt = SyncLimits.clampSyncTimestamp(claimed, now)138+            if ((inbox[id]?.modifiedAt ?: 0L) < modifiedAt) {139+                inbox[id] = LockedSyncCachedEntry(modifiedAt, ciphertext)140+            }141+        }142+        write(current.copy(inboxEntries = inbox))143+    }144+145+    @Synchronized146+    internal fun stageTombstones(vaultId: String, tombstones: Map<String, Long>) {147+        val current = read()?.takeIf { it.vaultId == vaultId } ?: return148+        require(tombstones.size <= SyncLimits.MAX_TOMBSTONE_COUNT) { "sync_tombstone_count_exceeded" }149+        val now = System.currentTimeMillis()150+        val inbox = current.inboxTombstones.toMutableMap()151+        tombstones.forEach { (id, timestamp) ->152+            if (timestamp <= 0L) return@forEach153+            val deletedAt = SyncLimits.clampSyncTimestamp(timestamp, now)154+            if ((inbox[id] ?: 0L) < deletedAt) inbox[id] = deletedAt155+        }156+        write(current.copy(inboxTombstones = inbox))157+    }158+159+    internal fun snapshot(vaultId: String): LockedSyncSpool? = read()?.takeIf { it.vaultId == vaultId }160+    fun clear() { prefs.edit().remove(KEY_STATE).commit() }161+162+    private fun read(): LockedSyncSpool? {163+        val raw = prefs.getString(KEY_STATE, null) ?: return null164+        return runCatching { json.decodeFromString(LockedSyncSpool.serializer(), raw) }165+            .getOrNull()166+            ?.takeIf { it.version == 1 && it.vaultId.matches(Regex("^[0-9a-f]{64}$")) }167+    }168+169+    private fun write(state: LockedSyncSpool) {170+        check(prefs.edit().putString(KEY_STATE, json.encodeToString(LockedSyncSpool.serializer(), state)).commit()) {171+            "locked_sync_spool_write_failed"172+        }173+    }174+175+    private companion object {176+        const val PREFS_FILE = "peach_locked_sync_spool_v1"177+        const val KEY_STATE = "state"178+        val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }179+    }180+}181+182+internal class LockedSyncVaultAdapter(183+    private val store: LockedSyncSpoolStore,184+    private val vaultId: String,185+) : SyncVaultAdapter {186+    private fun state() = store.snapshot(vaultId) ?: throw VaultLockedException()187+    override suspend fun computeVaultId() = vaultId188+    override suspend fun getOurDeviceId() = state().deviceId189+    override suspend fun getLocalVersion() = state().localVersion190+    override suspend fun buildEntryManifest() = state().entries.mapValues { it.value.modifiedAt }191+    override suspend fun buildTombstoneManifest() = state().tombstones192+    override suspend fun getEncryptedEntries(entryIds: List<String>): Map<String, String> {193+        val entries = state().entries194+        return entryIds.mapNotNull { id -> entries[id]?.ciphertext?.let { id to it } }.toMap()195+    }196+    override suspend fun applyEntries(entries: Map<String, String>, remoteManifest: Map<String, Long>) =197+        store.stageEntries(vaultId, entries, remoteManifest)198+    override suspend fun applyTombstones(tombstones: Map<String, Long>) =199+        store.stageTombstones(vaultId, tombstones)200+}201+202+internal object LockedSyncSpoolHooks {203+    @Volatile var refresh: (VaultRepository) -> Unit = {}204+}

view file

app/src/main/java/com/peachpasswords/sync/PeachSyncService.kt+12 −9
@@ -23,6 +23,7 @@2323 import kotlinx.coroutines.CoroutineScope2424 import kotlinx.coroutines.SupervisorJob2525 import kotlinx.coroutines.cancel26+import kotlinx.coroutines.launch2627 import javax.inject.Inject2728 2829 /**@@ -43,6 +44,7 @@4344     @Inject lateinit var pendingVouchedStore: PendingVouchedDeviceStore4445     @Inject lateinit var syncAlertStore: SyncAlertStore4546     @Inject lateinit var syncOutcomes: SyncOutcomeStore47+    @Inject lateinit var lockedSyncSpool: LockedSyncSpoolStore4648 4749     private val scope = CoroutineScope(SupervisorJob())4850 @@ -62,6 +64,9 @@6264             stopSelf()6365             return START_NOT_STICKY6466         }67+        if (vault.isUnlocked.value) {68+            scope.launch { runCatching { lockedSyncSpool.drainInto(vault) } }69+        }6570         val listenerStarted = startReconnectListener()6671         // A start command means something changed — a new pairing, a manual6772         // "sync now", the app coming forward. Re-read the device list now@@ -86,10 +91,8 @@8691     }8792 8893     /**89-     * Only possible while unlocked — the sync identity is derived from the90-     * vault's root key, so a locked phone genuinely has nothing to listen with.91-     * That is also why this is re-attempted on every start command rather than92-     * once: the service may well come up before the user has unlocked.94+     * Uses the Keystore-encrypted transport identity after full lock. The spool95+     * has no vault root and can only move opaque entry envelopes.9396      *9497      * The early return is deliberately the one terminating path that reports no9598      * outcome. Coming up locked is the normal boot order, not a failed sync;@@ -99,12 +102,12 @@99102      * mid-handshake.100103      */101104     private fun startReconnectListener(): Boolean {102-        val master = vault.syncMasterKey() ?: return false105+        val master = vault.syncMasterKey()103106         return try {104-            val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(this))105-            val identity = buildLocalIdentity(this, master)107+            if (master != null) lockedSyncSpool.refresh(vault)108+            val (keys, identity) = lockedSyncSpool.identity() ?: return false106109             cloudRelayListener.start(scope, keys.secretKey, keys.publicKey, identity) {107-                if (!vault.isUnlocked.value) null else VaultSyncAdapter(110+                if (!vault.isUnlocked.value) lockedSyncSpool.adapter() else VaultSyncAdapter(108111                     repo = vault,109112                     ourDeviceId = getOrCreateDeviceId(this),110113                     pairedDevices = deviceStore,@@ -113,7 +116,7 @@113116                 )114117             }115118         } finally {116-            VaultCrypto.zero(master)119+            master?.let(VaultCrypto::zero)117120         }118121     }119122 

view file

app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt+6 −3
@@ -53,7 +53,10 @@5353     suspend fun getEncryptedEntries(entryIds: List<String>): Map<String, String>5454 5555     /** Apply incoming encrypted entries; the vault decides per-entry overwrite. */56-    suspend fun applyEntries(entries: Map<String, String>)56+    suspend fun applyEntries(57+        entries: Map<String, String>,58+        remoteManifest: Map<String, Long> = emptyMap(),59+    )5760 5861     /** Apply incoming tombstones; the vault retains the latest deletion ts. */5962     suspend fun applyTombstones(tombstones: Map<String, Long>)@@ -135,7 +138,7 @@135138 136139             if (wantFromPeer.isNotEmpty()) {137140                 val incoming = session.recvAs<SyncMessage.Entries>("initiator", "SYNC_ENTRIES")138-                vault.applyEntries(incoming.entries)141+                vault.applyEntries(incoming.entries, peerManifest.entries)139142                 entriesReceived = incoming.entries.size140143             }141144 @@ -232,7 +235,7 @@232235             // Drain the initiator's unconditional SYNC_ENTRIES first.233236             val incoming = session.recvAs<SyncMessage.Entries>("responder", "SYNC_ENTRIES")234237             if (wantFromPeer.isNotEmpty()) {235-                vault.applyEntries(incoming.entries)238+                vault.applyEntries(incoming.entries, peerManifest.entries)236239                 entriesReceived = incoming.entries.size237240             }238241             // Only emit ours when the initiator asked — its recv is conditional.

view file

app/src/main/java/com/peachpasswords/sync/SyncFreshStartResetter.kt+4 −0
@@ -18,6 +18,7 @@1818     private val outcomes: SyncOutcomeStore,1919     private val relaySettings: RelaySettingsStore,2020     private val backgroundSync: BackgroundSyncPreference,21+    private val lockedSyncSpool: LockedSyncSpoolStore,2122 ) {2223     fun reset() {2324         resetSyncStateForFreshStart(@@ -29,6 +30,7 @@2930             clearRelaySettings = relaySettings::reset,3031             clearBackgroundPreference = backgroundSync::reset,3132             clearLocalIdentity = { clearDeviceIdentity(context) },33+            clearLockedSyncSpool = lockedSyncSpool::clear,3234         )3335     }3436 }@@ -42,6 +44,7 @@4244     clearRelaySettings: () -> Unit,4345     clearBackgroundPreference: () -> Unit,4446     clearLocalIdentity: () -> Unit,47+    clearLockedSyncSpool: () -> Unit = {},4548 ) {4649     stopService()4750     clearDevices()@@ -51,4 +54,5 @@5154     clearRelaySettings()5255     clearBackgroundPreference()5356     clearLocalIdentity()57+    clearLockedSyncSpool()5458 }

view file

app/src/main/java/com/peachpasswords/sync/SyncLimits.kt+4 −0
@@ -23,6 +23,10 @@2323  * Both sides must agree, so change them together.2424  */2525 object SyncLimits {26+    const val MAX_ENTRY_COUNT = 100_00027+    const val MAX_TOMBSTONE_COUNT = 100_00028+    const val MAX_ENTRY_PLAINTEXT_BYTES = 512 * 102429+    const val MAX_ENTRY_ENVELOPE_BYTES = (MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 642630     /**2731      * HV-1: maximum tolerated forward clock skew on any timestamp received from2832      * a sync peer. A peer whose clock is slightly ahead still merges cleanly,

view file

app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+54 −47
@@ -87,7 +87,10 @@8787         }8888     }8989 90-    override suspend fun applyEntries(entries: Map<String, String>) {90+    override suspend fun applyEntries(91+        entries: Map<String, String>,92+        remoteManifest: Map<String, Long>,93+    ) {9194         val snapshot = requireMasterKeySnapshot()9295         val master = snapshot.key9396         val decrypted = try {@@ -314,59 +317,63 @@314317     private fun requireMasterKeySnapshot(): VaultRepository.SyncMasterKeySnapshot =315318         repo.syncMasterKeySnapshot() ?: throw VaultLockedException()316319 317-318-    private fun deriveEntryKey(masterKey: ByteArray, entryId: String): ByteArray {319-        // Null-byte separator (HKDF hardening): entryId cannot contain U+0000, so320-        // the (entryId, suffix) boundary is unambiguous regardless of future id321-        // formats. Coordinated wire change — peach-browser deriveEntryKey produces322-        // the identical info bytes; the two MUST stay byte-for-byte in lockstep.323-        val info = (entryId + "\u0000" + ENTRY_KEY_INFO_SUFFIX).toByteArray(Charsets.UTF_8)324-        val gen = HKDFBytesGenerator(SHA256Digest())325-        gen.init(HKDFParameters(masterKey, ByteArray(0), info))326-        return ByteArray(32).also { gen.generateBytes(it, 0, it.size) }327-    }328-329-    private fun encryptEntry(key: ByteArray, entry: VaultEntry): String {330-        // M-05: plaintext is the entry JSON (incl. password field). Zero in331-        // finally so the serialized copy doesn't outlive the ciphertext.332-        var plaintext: ByteArray? = null333-        return try {334-            plaintext = entryJson.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)335-            val nonce = ByteArray(12).also(rng::nextBytes)336-            val cipher = Cipher.getInstance("AES/GCM/NoPadding")337-            cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))338-            cipher.updateAAD(ENTRY_AAD)339-            val ct = cipher.doFinal(plaintext)340-            b64(nonce + ct)341-        } finally {342-            plaintext?.let(VaultCrypto::zero)320+    internal companion object {321+        internal fun deriveEntryKey(masterKey: ByteArray, entryId: String): ByteArray {322+            // Null-byte separator (HKDF hardening): entryId cannot contain U+0000, so323+            // the (entryId, suffix) boundary is unambiguous regardless of future id324+            // formats. Coordinated wire change — peach-browser deriveEntryKey produces325+            // the identical info bytes; the two MUST stay byte-for-byte in lockstep.326+            val info = (entryId + "\u0000" + ENTRY_KEY_INFO_SUFFIX).toByteArray(Charsets.UTF_8)327+            val gen = HKDFBytesGenerator(SHA256Digest())328+            gen.init(HKDFParameters(masterKey, ByteArray(0), info))329+            return ByteArray(32).also { gen.generateBytes(it, 0, it.size) }343330         }344-    }345331 346-    private fun decryptEntryOrNull(key: ByteArray, envelopeB64: String): VaultEntry? {347-        var pt: ByteArray? = null348-        return try {349-            val bytes = unb64(envelopeB64)350-            if (bytes.size < 12 + 16) null else {351-                val nonce = bytes.copyOfRange(0, 12)352-                val ct = bytes.copyOfRange(12, bytes.size)332+        internal fun encryptEntry(key: ByteArray, entry: VaultEntry): String {333+            // M-05: plaintext is the entry JSON (incl. password field). Zero in334+            // finally so the serialized copy doesn't outlive the ciphertext.335+            var plaintext: ByteArray? = null336+            return try {337+                plaintext = entryJson.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)338+                val nonce = ByteArray(12).also(rng::nextBytes)353339                 val cipher = Cipher.getInstance("AES/GCM/NoPadding")354-                cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))340+                cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))355341                 cipher.updateAAD(ENTRY_AAD)356-                pt = cipher.doFinal(ct)357-                entryJson.decodeFromString(VaultEntry.serializer(), String(pt, Charsets.UTF_8))342+                val ct = cipher.doFinal(plaintext)343+                b64(nonce + ct)344+            } finally {345+                plaintext?.let(VaultCrypto::zero)358346             }359-        } catch (_: Exception) {360-            null361-        } finally {362-            // M-05: pt holds the serialized entry including password until the363-            // decoded VaultEntry above is the only remaining reference. Zero it364-            // so the raw bytes don't outlive the structure they were parsed into.365-            pt?.let(VaultCrypto::zero)366347         }367-    }368348 369-    private companion object {349+        internal fun encryptEntryWithMasterKey(masterKey: ByteArray, entry: VaultEntry): String {350+            val key = deriveEntryKey(masterKey, entry.id)351+            return try { encryptEntry(key, entry) } finally { VaultCrypto.zero(key) }352+        }353+354+        internal fun decryptEntryOrNull(key: ByteArray, envelopeB64: String): VaultEntry? {355+            var pt: ByteArray? = null356+            return try {357+                val bytes = unb64(envelopeB64)358+                if (bytes.size < 12 + 16) null else {359+                    val nonce = bytes.copyOfRange(0, 12)360+                    val ct = bytes.copyOfRange(12, bytes.size)361+                    val cipher = Cipher.getInstance("AES/GCM/NoPadding")362+                    cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))363+                    cipher.updateAAD(ENTRY_AAD)364+                    pt = cipher.doFinal(ct)365+                    entryJson.decodeFromString(VaultEntry.serializer(), String(pt, Charsets.UTF_8))366+                }367+            } catch (_: Exception) {368+                null369+            } finally {370+                // M-05: pt holds the serialized entry including password until the371+                // decoded VaultEntry above is the only remaining reference. Zero it372+                // so the raw bytes don't outlive the structure they were parsed into.373+                pt?.let(VaultCrypto::zero)374+            }375+        }376+370377         const val ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1"371378         val ENTRY_AAD = "peach-sync-entry-v1".toByteArray(Charsets.UTF_8)372379         val rng = SecureRandom()

view file

app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+14 −0
@@ -13,6 +13,7 @@1313 import androidx.annotation.VisibleForTesting1414 import com.peachpasswords.BuildConfig1515 import com.peachpasswords.sync.SyncLimits16+import com.peachpasswords.sync.LockedSyncSpoolHooks1617 import kotlinx.serialization.json.JsonElement1718 import kotlinx.serialization.json.JsonNull1819 import kotlinx.serialization.json.JsonObject@@ -1204,6 +1205,17 @@12041205     }12051206 12061207     fun lock() {1208+        // Never snapshot a mutation that has changed entryList but has not yet1209+        // crossed its durable epoch fence. Successful writes refresh the spool1210+        // before releasing this mutex; an in-flight writer therefore makes this1211+        // lock skip the convenience refresh and fail closed to the prior cache.1212+        if (mutex.tryLock()) {1213+            try {1214+                runCatching { LockedSyncSpoolHooks.refresh(this) }1215+            } finally {1216+                mutex.unlock()1217+            }1218+        }12071219         // H-03: must observe derivedKey/entryList atomically with any in-flight12081220         // suspend op (an addEntry mid-encrypt, an unlock adopting the payload,12091221         // etc.). mutex cannot be acquired from this non-suspend caller; stateLock@@ -2057,6 +2069,7 @@20572069         if (applied > 0) {20582070             persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)20592071             mutateIfCurrentEpoch(epoch) { publish() }2072+            runCatching { LockedSyncSpoolHooks.refresh(this) }20602073         }20612074         applied20622075     }@@ -2926,5 +2939,6 @@29262939         // before publication. The epoch check prevents pre-lock entries from29272940         // being republished into a newly locked (or newly unlocked) session.29282941         mutateIfCurrentEpoch(expectedEpoch) { publish() }2942+        runCatching { LockedSyncSpoolHooks.refresh(this) }29292943     }29302944 }

view file

app/src/test/java/com/peachpasswords/sync/CloudRelayReconnectListenerTest.kt+1 −1
@@ -27,7 +27,7 @@2727     override suspend fun buildEntryManifest() = emptyMap<String, Long>()2828     override suspend fun buildTombstoneManifest() = emptyMap<String, Long>()2929     override suspend fun getEncryptedEntries(entryIds: List<String>) = emptyMap<String, String>()30-    override suspend fun applyEntries(entries: Map<String, String>) {}30+    override suspend fun applyEntries(entries: Map<String, String>, remoteManifest: Map<String, Long>) {}3131     override suspend fun applyTombstones(tombstones: Map<String, Long>) {}3232 }3333 

view file

app/src/test/java/com/peachpasswords/sync/SyncCoordinatorTest.kt+5 −1
@@ -36,6 +36,7 @@3636     var outgoingDeviceManifest: SignedDeviceManifest? = null3737     var incomingDeviceManifest: SignedDeviceManifest? = null3838     var incomingAuthenticatedKey: ByteArray? = null39+    var lastRemoteManifest: Map<String, Long>? = null3940 4041     fun put(id: String, mtime: Long) { entries[id] = mtime; blobs[id] = mtime.toString() }4142 @@ -46,7 +47,8 @@4647     override suspend fun buildTombstoneManifest() = tombstones.toMap()4748     override suspend fun getEncryptedEntries(entryIds: List<String>) =4849         entryIds.filter { blobs.containsKey(it) }.associateWith { blobs[it]!! }49-    override suspend fun applyEntries(entries: Map<String, String>) {50+    override suspend fun applyEntries(entries: Map<String, String>, remoteManifest: Map<String, Long>) {51+        lastRemoteManifest = remoteManifest5052         for ((id, blob) in entries) { blobs[id] = blob; this.entries[id] = blob.toLong() }5153     }5254     override suspend fun applyTombstones(tombstones: Map<String, Long>) {@@ -88,6 +90,8 @@8890         // A pulled y; B pulled x.8991         assertTrue(a.blobs.containsKey("y"))9092         assertTrue(b.blobs.containsKey("x"))93+        assertEquals(mapOf("y" to 200L), a.lastRemoteManifest)94+        assertEquals(mapOf("x" to 100L), b.lastRemoteManifest)9195         assertEquals(1, outA.entriesReceived)9296         assertEquals(1, outA.entriesSent)9397         val messages = logs.map { it.second }

view file

app/src/test/java/com/peachpasswords/sync/SyncFreshStartResetterTest.kt+2 −0
@@ -16,6 +16,7 @@1616             clearRelaySettings = { calls += "relay" },1717             clearBackgroundPreference = { calls += "background" },1818             clearLocalIdentity = { calls += "identity" },19+            clearLockedSyncSpool = { calls += "spool" },1920         )2021 2122         assertEquals(@@ -28,6 +29,7 @@2829                 "relay",2930                 "background",3031                 "identity",32+                "spool",3133             ),3234             calls,3335         )

view file

app/src/test/java/com/peachpasswords/vault/SessionEpochRaceTest.kt+6 −0
@@ -8,6 +8,8 @@88 import java.util.concurrent.CountDownLatch99 import java.util.concurrent.TimeUnit1010 import java.util.concurrent.atomic.AtomicReference11+import java.util.concurrent.atomic.AtomicInteger12+import com.peachpasswords.sync.LockedSyncSpoolHooks1113 1214 /**1315  * Deterministic H-03 races. The storage hook pauses a writer after it has@@ -57,6 +59,8 @@5759         val paused = CountDownLatch(1)5860         val release = CountDownLatch(1)5961         val failure = AtomicReference<Throwable?>()62+        val spoolRefreshes = AtomicInteger()63+        LockedSyncSpoolHooks.refresh = { spoolRefreshes.incrementAndGet() }6064         repo.beforeDurableCommit = {6165             paused.countDown()6266             check(release.await(5, TimeUnit.SECONDS)) { "test writer did not release" }@@ -80,8 +84,10 @@8084         writer.join(5_000)8185         assertFalse("writer must not remain blocked", writer.isAlive)8286         assertTrue("stale writer should fail closed", failure.get() is IllegalStateException)87+        assertEquals("uncommitted state must never enter the sync spool", 0, spoolRefreshes.get())8388 8489         repo.beforeDurableCommit = {}90+        LockedSyncSpoolHooks.refresh = {}8591         assertTrue(repo.unlock("pass") is UnlockResult.Success)8692         assertEquals(listOf("before"), repo.getEntries().map { it.title })8793     }

view file

app/src/test/java/com/peachpasswords/vault/VaultRepositoryTest.kt+25 −0
@@ -6,6 +6,7 @@66 import org.junit.Assert.assertNull77 import org.junit.Assert.assertTrue88 import org.junit.Test9+import com.peachpasswords.sync.LockedSyncSpoolHooks910 1011 /** In-memory [VaultStorage] so the repository can be exercised without Android. */1112 private class FakeStorage : VaultStorage {@@ -40,6 +41,30 @@4041 4142     private fun note(title: String) =4243         VaultEntry(id = "", title = title, type = "note", createdAt = 0, modifiedAt = 0)44+45+    @Test fun full_lock_offers_committed_state_to_opaque_sync_spool_before_zeroing() = runBlocking {46+        val repo = VaultRepository(FakeStorage())47+        repo.createVault("pw")48+        repo.addEntry(login("GitHub", "secret"))49+        var observedRoot = false50+        var observedTitles = emptyList<String>()51+        LockedSyncSpoolHooks.refresh = { current ->52+            val root = current.syncMasterKey()53+            observedRoot = root != null54+            root?.let(VaultCrypto::zero)55+            observedTitles = current.getEntries().map { it.title }56+        }57+        try {58+            repo.lock()59+            assertTrue(observedRoot)60+            assertEquals(listOf("GitHub"), observedTitles)61+            assertFalse(repo.isUnlocked.value)62+            assertNull(repo.syncMasterKey())63+            assertTrue(repo.getEntries().isEmpty())64+        } finally {65+            LockedSyncSpoolHooks.refresh = {}66+        }67+    }4368 4469     @Test fun create_persists_and_unlocks() = runBlocking {4570         val storage = FakeStorage()

view file