093d52874539

M-07: transient background-lock suppression carries a deadline; expired flag never suppresses a real lock

time
2026-07-29 15:02 UTC · 2 months ago
machine
koh2
agent
claude-opus-4
session
audit-remediation-2026-07-29
parent
20500a0671be
2 files app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+28 −6app/src/test/java/com/peachpasswords/vault/VaultRepositoryTest.kt+27 −0
app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+28 −6
@@ -8,7 +8,9 @@88 import java.security.SecureRandom99 import java.text.Normalizer1010 import kotlinx.serialization.json.Json11+import android.os.SystemClock1112 import android.util.Log13+import androidx.annotation.VisibleForTesting1214 import com.peachpasswords.BuildConfig1315 import com.peachpasswords.sync.SyncLimits1416 import kotlinx.serialization.json.JsonElement@@ -31,6 +33,13 @@3133 private const val PASSWORD_HISTORY_MAX = 503234 private const val VAULT_HISTORY_MAX = 53335 36+// How long the transient-background-lock suppression stays valid. If the37+// launched system Activity never reports the expected lifecycle stop (picker38+// crashed, user backed out into another app, OEM killed the picker process),39+// the flag expires and the next genuine background event locks the vault40+// rather than consuming a stale suppression.41+private const val BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS = 30_000L42+3443 sealed interface UnlockResult {3544     data object Success : UnlockResult3645     data object WrongPassword : UnlockResult@@ -746,8 +755,13 @@746755         }747756     }748757 749-    @Volatile private var suppressNextBackgroundLock = false758+    @Volatile private var suppressNextBackgroundLockUntil: Long = 0L750759 760+    // Test seam so the expiry branch is reachable from plain JUnit (SystemClock761+    // is a stub off-device).762+    @VisibleForTesting763+    internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }764+751765     /**752766      * Call immediately before launching a system Activity (a SAF file753767      * picker/save dialog, a share sheet) that briefly takes the whole process@@ -756,16 +770,24 @@756770      * two apart on its own (it fires for both), and without this, exporting or757771      * importing a file silently locked and wiped the vault mid-flow the moment758772      * the picker took over, before its result ever reached the app.773+     *774+     * The suppression auto-expires after [BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS]775+     * (M-07); a never-consumed stale flag cannot suppress a later real lock.759776      */760777     fun expectTransientBackground() {761-        suppressNextBackgroundLock = true778+        suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS762779     }763780 764-    /** Consumed once by the next background-lock check; see [expectTransientBackground]. */781+    /**782+     * Consumed once by the next background-lock check; see [expectTransientBackground].783+     * Returns false (and clears the flag) if the suppression has expired or was784+     * never set — a genuine background event must always lock.785+     */765786     fun consumeTransientBackgroundSuppression(): Boolean {766-        val suppressed = suppressNextBackgroundLock767-        suppressNextBackgroundLock = false768-        return suppressed787+        val until = suppressNextBackgroundLockUntil788+        val active = until != 0L && nowMs() < until789+        suppressNextBackgroundLockUntil = 0L790+        return active769791     }770792 771793     // ---- Sync identity ----------------------------------------------------

view file

app/src/test/java/com/peachpasswords/vault/VaultRepositoryTest.kt+27 −0
@@ -463,4 +463,31 @@463463         assertNull(storage.slotA)464464         assertFalse(repo.hasVault())465465     }466+467+    // M-07: the transient-background suppression must expire so a stale flag468+    // can never suppress a later real lock. Drives the clock via the visible-469+    // for-testing seam in VaultRepository.470+    @Test fun transient_background_suppression_expires() = runBlocking {471+        val storage = FakeStorage()472+        val repo = VaultRepository(storage)473+        var fakeNow = 0L474+        repo.nowMs = { fakeNow }475+476+        // Never set — must always report not-suppressed.477+        assertFalse(repo.consumeTransientBackgroundSuppression())478+479+        // Set, still within the window — must report suppressed and clear.480+        repo.expectTransientBackground()481+        assertTrue(repo.consumeTransientBackgroundSuppression())482+        assertFalse(repo.consumeTransientBackgroundSuppression())483+484+        // Set, then advance the clock past the 30s timeout — must report485+        // not-suppressed (would-have-been-stale flag cannot suppress a real486+        // background event) and clear.487+        fakeNow = 0L488+        repo.expectTransientBackground()489+        fakeNow += 30_000L490+        assertFalse(repo.consumeTransientBackgroundSuppression())491+        assertFalse(repo.consumeTransientBackgroundSuppression())492+    }466493 }

view file