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
↓ peach-android-c7780c7f.tar.gzatlas at this save
files changed14 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 −0app/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()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+}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 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.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 }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,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()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 }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 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 }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 )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 }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()