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
↓ peach-android-3b1a8ee6.tar.gzatlas at this save
files changed7 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 −1app/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 {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()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 }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 stableapp/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 }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