3b1a8ee6b9fb

stop the duress session presenting a sync identity, while leaving codex and recovery words working on the decoy root key so neither becomes a duress tell (v2.4.7)

time
2026-07-26 08:51 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
sync campaign remaining items
parent
1a3d936afbab
7 files VERSION+1 −1app/build.gradle+2 −2app/src/main/java/com/peachpasswords/ui/codex/CodexViewModel.kt+1 −1app/src/main/java/com/peachpasswords/ui/vault/RecoveryWordsScreen.kt+2 −2app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+27 −3app/src/test/java/com/peachpasswords/vault/DuressSlotBTest.kt+32 −0app/src/test/java/com/peachpasswords/vault/RecoveryEscrowSuiteTest.kt+1 −1
VERSION+1 −1
@@ -1 +1 @@1-2.4.61+2.4.7

view file

app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 24000620-        versionName "2.4.6"19+        versionCode 24000720+        versionName "2.4.7"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/ui/codex/CodexViewModel.kt+1 −1
@@ -80,7 +80,7 @@8080     fun expectTransientBackground() = vault.expectTransientBackground()8181 8282     private val recoveryWords: List<String> by lazy {83-        vault.syncMasterKey()?.let { k ->83+        vault.sessionRootKey()?.let { k ->8484             try { com.peachpasswords.vault.rootKeyToWords(k) }8585             finally { com.peachpasswords.vault.VaultCrypto.zero(k) }8686         } ?: emptyList()

view file

app/src/main/java/com/peachpasswords/ui/vault/RecoveryWordsScreen.kt+2 −2
@@ -83,7 +83,7 @@8383         viewModelScope.launch {8484             vault.isUnlocked.collect { unlocked ->8585                 if (unlocked && _words.value.isEmpty()) {86-                    _words.value = vault.syncMasterKey()?.let { key ->86+                    _words.value = vault.sessionRootKey()?.let { key ->8787                         try { rootKeyToWords(key) } finally { VaultCrypto.zero(key) }8888                     } ?: emptyList()8989                 }@@ -99,7 +99,7 @@9999      */100100     fun refresh() {101101         if (_words.value.isEmpty()) {102-            _words.value = vault.syncMasterKey()?.let { key ->102+            _words.value = vault.sessionRootKey()?.let { key ->103103                 try { rootKeyToWords(key) } finally { VaultCrypto.zero(key) }104104             } ?: emptyList()105105         }

view file

app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+27 −3
@@ -557,10 +557,34 @@557557      * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry558558      * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].559559      */560-    fun syncMasterKey(): ByteArray? = rootKey?.copyOf()560+    /**561+     * HOLE 5 — the duress vault never syncs.562+     *563+     * [commitDuressUnlock] gives the decoy session a root key so its own codex564+     * and recovery paths work, which means (unlike the browser, where the decoy565+     * path simply has no root key) the sync surface has to refuse explicitly.566+     * Without this the decoy vault would present a second, different vault_id567+     * to a paired device, and a coerced user opening it would make sync visibly568+     * fail on their other devices — a duress indicator, which is the one thing569+     * a duress vault must never be.570+     */571+    fun syncMasterKey(): ByteArray? = if (decoyMode) null else rootKey?.copyOf()561572 562-    /** Stable per-vault id two paired devices must share to sync. Null when locked. */563-    fun syncVaultId(): String? = rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }573+    /**574+     * The current session's root key, decoy included. For local-only uses that575+     * must behave identically in a decoy session — Codex backup and recovery576+     * words — because a feature that errors under duress is itself a duress577+     * tell. In a decoy session this is the DECOY's root key (see578+     * [commitDuressUnlock]), never the real vault's, so neither surface can579+     * leak the real vault. Anything that leaves the device must use580+     * [syncMasterKey] instead.581+     */582+    fun sessionRootKey(): ByteArray? = rootKey?.copyOf()583+584+    /** Stable per-vault id two paired devices must share to sync. Null when585+     *  locked, and never present for the decoy vault (see [syncMasterKey]). */586+    fun syncVaultId(): String? =587+        if (decoyMode) null else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }564588 565589     /**566590      * Browser parity (MV-8): rotate the sync identity by replacing the stable

view file

app/src/test/java/com/peachpasswords/vault/DuressSlotBTest.kt+32 −0
@@ -83,4 +83,36 @@8383         repo.lock()8484         assertNull("duress slot gone after clear", repo.tryUnlockDuress(duressPass))8585     }86+87+    @Test fun `HOLE 5 the duress vault has no sync identity and cannot sync`() = runBlocking {88+        val repo = configuredRepo()89+90+        // Baseline: the real vault does have a sync identity.91+        assertNotNull(repo.tryUnlockSlotA(real))92+        assertEquals(UnlockResult.Success, repo.unlock(real))93+        val realVaultId = repo.syncVaultId()94+        assertNotNull("the real vault syncs", realVaultId)95+        repo.lock()96+97+        // The decoy session must expose neither a sync key nor a vault id.98+        // A second vault_id would be a duress indicator: a coerced user opening99+        // the decoy would make sync visibly fail on their other devices.100+        val duress = repo.tryUnlockDuress(duressPass)101+        assertNotNull(duress)102+        assertEquals(UnlockResult.Success, repo.commitDuressUnlock(duress!!))103+        assertTrue(repo.isUnlocked.value)104+        assertNull("the duress vault must expose no sync key", repo.syncMasterKey())105+        assertNull("the duress vault must expose no vault id", repo.syncVaultId())106+107+        // Every sync adapter operation therefore fails closed rather than108+        // syncing, manifesting, or revealing an entry count.109+        val adapter = com.peachpasswords.sync.VaultSyncAdapter(repo, ourDeviceId = "decoy-device")110+        var threw = false111+        try {112+            adapter.computeVaultId()113+        } catch (_: Exception) {114+            threw = true115+        }116+        assertTrue("the decoy vault must not produce a vault id for sync", threw)117+    }86118 }

view file

app/src/test/java/com/peachpasswords/vault/RecoveryEscrowSuiteTest.kt+1 −1
@@ -36,7 +36,7 @@3636     }3737 3838     private fun wordsOf(repo: VaultRepository): List<String> {39-        val key = repo.syncMasterKey()!!39+        val key = repo.sessionRootKey()!!4040         return try { rootKeyToWords(key) } finally { VaultCrypto.zero(key) }4141     }4242 

view file