ff6562aacdb4

fix: Install the browser duress slot from the adoption snapshot (v2.9.3)

Completes the H-04 duress-propagation series (2.9.1/2.9.2). A duress configured on the extension must survive adopting the vault onto Android. The 2.9.0 inline adoption snapshot shipped entries + tombstones but not the duress slot, so phone-pairing adoption silently dropped a configured decoy. The Codex-restore path already carried it (v2.9.2); this wires the same passthrough into the adoption snapshot receiver. Coordinated with peach-browser v4.6.0 (7410c0da42c1). Wire contract (additive, backward-compatible; both ends now shipped): - ADOPT_SNAPSHOT gains optional duress_slot?: string = JSON.stringify of the browser-shaped slot B, identical to the Codex vault_b field. - Final chunk only, and only when the source has duress configured; absent otherwise (never ships noise). Old sources omit it and adopt cleanly. Changes: - sync/VaultAdopt.kt: duress_slot on Snapshot; ReceivedSnapshot result type; sendVaultSnapshot/receiveVaultSnapshot thread it; applySnapshot gains a defaulted duressSlot param. AdoptJson encodeDefaults=false omits a null slot. - vault/VaultRepository.kt: installAdoptedDuressSlot(json) — BrowserSlot.parse, re-encode hex->base64 nonce||ct||tag (the browserDuressPassthrough logic) with NO decrypt, epoch-fenced writeDecoySlot, M-05 zeroing, false on any failure. - sync/RelayAdoptionReceiver.kt: thread snapshot.duressSlot into applySnapshot. - ui/pairing/AdoptVaultViewModel.kt: install after entries+tombstones in an isolated try/catch — a failure is logged and adoption continues (non-fatal). Android-as-source does not yet send its own duress slot (sendVaultSnapshot duressSlot defaults null) — queued follow-up. Verification (orchestrator-independent): testDebugUnitTest + lintDebug + assembleRelease --rerun-tasks BUILD SUCCESSFUL; 117 suites, 833 tests, 0 failures/errors/skips (baseline 825/116, +8 tests +1 suite). AdoptDuressSlotTest proves install -> tryUnlockDuress(duressPassphrase) opens the adopted decoy.

time
2026-07-30 12:12 UTC · 2 months ago
machine
koh2
parent
cb62379d810a
7 files VERSION+1 −1app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+6 −6app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+19 −3app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+10 −1app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+45 −0app/src/test/java/com/peachpasswords/sync/VaultAdoptSnapshotTest.kt+46 −0app/src/test/java/com/peachpasswords/vault/AdoptDuressSlotTest.kt+143 −0
VERSION+1 −1
@@ -1 +1 @@1-2.9.21+2.9.3

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+6 −6
@@ -219,8 +219,8 @@219219             override fun onError(error: String) {220220                 receiverDeps.onError(error)221221             }222-            override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>) {223-                receiverDeps.applySnapshot(entries, tombstones)222+            override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String?) {223+                receiverDeps.applySnapshot(entries, tombstones, duressSlot)224224             }225225             override suspend fun markSynced(deviceId: String) {226226                 receiverDeps.markSynced(deviceId)@@ -229,13 +229,13 @@229229         val adopted = requestAdoption(channel, boundDeps) ?: return null230230         // Try to receive the inline vault snapshot.231231         try {232-            val (entries, tombstones) = withTimeout(15_000) {232+            val snapshot = withTimeout(15_000) {233233                 receiveVaultSnapshot(channel)234234             }235-            boundDeps.applySnapshot(entries, tombstones)235+            boundDeps.applySnapshot(snapshot.entries, snapshot.tombstones, snapshot.duressSlot)236236             sendFrame(channel, AdoptionFrame.SnapshotApplied(237-                entriesReceived = entries.size,238-                tombstonesReceived = tombstones.size,237+                entriesReceived = snapshot.entries.size,238+                tombstonesReceived = snapshot.tombstones.size,239239             ))240240             // Optional device manifest from source241241             try {

view file

app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+19 −3
@@ -99,6 +99,7 @@9999         @SerialName("chunk_count") val chunkCount: Int,100100         val entries: Map<String, String>,101101         val tombstones: Map<String, Long>,102+        @SerialName("duress_slot") val duressSlot: String? = null,102103     ) : AdoptionFrame()103104 104105     /** Receiver acknowledgement sent after all snapshot chunks are applied. */@@ -139,6 +140,17 @@139140 )140141 141142 /**143+ * Receiver-side reassembly result from [receiveVaultSnapshot]. Carries the144+ * aggregated entries and tombstones plus the optional browser duress slot145+ * JSON (present on the final chunk only, when the source has duress configured).146+ */147+data class ReceivedSnapshot(148+    val entries: Map<String, String>,149+    val tombstones: Map<String, Long>,150+    val duressSlot: String? = null,151+)152+153+/**142154  * Wire guardrails for the inline snapshot. A chunk's serialized entries map143155  * stays under [MAX_SNAPSHOT_CHUNK_BYTES]; the whole transfer is capped at144156  * [MAX_SNAPSHOT_CHUNKS] frames so a compromised source cannot exhaust receiver@@ -176,7 +188,7 @@176188     val pairState: AdoptionPairState? get() = null177189     fun onError(error: String)178190     /** Apply encrypted entries + tombstones from the inline snapshot. */179-    suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>) {}191+    suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String? = null) {}180192     /** Mark the paired device as synced after a successful snapshot. */181193     suspend fun markSynced(deviceId: String) {}182194 }@@ -450,6 +462,7 @@450462     channel: PairingTransport,451463     entries: Map<String, String>,452464     tombstones: Map<String, Long>,465+    duressSlot: String? = null,453466 ) {454467     val chunks = mutableListOf<Map<String, String>>()455468     var current = LinkedHashMap<String, String>()@@ -475,6 +488,7 @@475488             chunkCount = chunkCount,476489             entries = chunks[index],477490             tombstones = if (isFinal) tombstones else emptyMap(),491+            duressSlot = if (isFinal && !duressSlot.isNullOrBlank()) duressSlot else null,478492         ))479493     }480494 }@@ -488,9 +502,10 @@488502  */489503 suspend fun receiveVaultSnapshot(490504     channel: PairingTransport,491-): Pair<Map<String, String>, Map<String, Long>> {505+): ReceivedSnapshot {492506     val entries = LinkedHashMap<String, String>()493507     val tombstones = LinkedHashMap<String, Long>()508+    var duressSlot: String? = null494509     var chunkCount = -1495510     var received = 0496511     while (chunkCount == -1 || received < chunkCount) {@@ -516,9 +531,10 @@516531         }517532         entries.putAll(frame.entries)518533         tombstones.putAll(frame.tombstones)534+        if (frame.duressSlot != null) duressSlot = frame.duressSlot519535         received++520536     }521-    return entries to tombstones537+    return ReceivedSnapshot(entries, tombstones, duressSlot)522538 }523539 524540 private fun isDeviceType(value: String): Boolean =

view file

app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+10 −1
@@ -1,6 +1,7 @@11 package com.peachpasswords.ui.pairing22 33 import android.content.Context4+import android.util.Log45 import androidx.lifecycle.ViewModel56 import androidx.lifecycle.viewModelScope67 import com.peachpasswords.license.LicenseRepository@@ -314,7 +315,7 @@314315                 protocolError = error315316             }316317 317-            override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>) {318+            override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String?) {318319                 val adapter = VaultSyncAdapter(319320                     repo = vault,320321                     ourDeviceId = getOrCreateDeviceId(appContext),@@ -322,6 +323,14 @@322323                 )323324                 adapter.applyEntries(entries)324325                 adapter.applyTombstones(tombstones)326+                if (!duressSlot.isNullOrBlank()) {327+                    try {328+                        val ok = vault.installAdoptedDuressSlot(duressSlot)329+                        if (!ok) Log.w("PeachAdopt", "duress slot install returned false; adoption continues")330+                    } catch (e: Exception) {331+                        Log.w("PeachAdopt", "duress slot install failed; adoption continues", e)332+                    }333+                }325334             }326335 327336             override suspend fun markSynced(deviceId: String) {

view file

app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+45 −0
@@ -986,6 +986,51 @@986986         }987987     }988988 989+    /**990+     * Install a browser-shaped duress slot received through adoption.991+     *992+     * Parses [json] as a [BrowserSlotA], re-encodes it to Android's993+     * [StoredVault] via [browserDuressPassthrough] (no decryption), and994+     * writes the result to the decoy slot. Epoch-fenced like the Codex995+     * restore path so a concurrent lock() cannot strand the write.996+     *997+     * Returns false on invalid input, no live session, or write failure.998+     */999+    suspend fun installAdoptedDuressSlot(json: String): Boolean = mutex.withLock {1000+        val b = BrowserSlot.parse(json) ?: return false1001+        var stored: StoredVault? = null1002+        var salt: ByteArray? = null1003+        var nonce: ByteArray? = null1004+        var ct: ByteArray? = null1005+        var tag: ByteArray? = null1006+        var blob: ByteArray? = null1007+        try {1008+            salt = unhex(b.salt)1009+            nonce = unhex(b.nonce)1010+            ct = unhex(b.ciphertext)1011+            tag = unhex(b.tag)1012+            blob = ByteArray(nonce.size + ct.size + tag.size).also {1013+                System.arraycopy(nonce, 0, it, 0, nonce.size)1014+                System.arraycopy(ct, 0, it, nonce.size, ct.size)1015+                System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)1016+            }1017+            stored = StoredVault(version = 1, salt = b64(salt), blob = b64(blob))1018+            val expectedEpoch = sessionEpoch1019+            beforeDurableCommit()1020+            withEpochCommit(expectedEpoch) {1021+                store.writeDecoySlot(stored)1022+            } ?: false1023+        } catch (_: Exception) {1024+            false1025+        } finally {1026+            salt?.let(VaultCrypto::zero)1027+            nonce?.let(VaultCrypto::zero)1028+            ct?.let(VaultCrypto::zero)1029+            tag?.let(VaultCrypto::zero)1030+            blob?.let(VaultCrypto::zero)1031+        }1032+    }1033+9891034     /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */9901035     suspend fun clearDuress(): Boolean = mutex.withLock {9911036         // H-04: presence-indistinguishable — replace the real decoy with a fake

view file

app/src/test/java/com/peachpasswords/sync/VaultAdoptSnapshotTest.kt+46 −0
@@ -3,6 +3,7 @@33 import kotlinx.coroutines.channels.Channel44 import kotlinx.coroutines.runBlocking55 import org.junit.Assert.assertEquals6+import org.junit.Assert.assertNull67 import org.junit.Assert.assertTrue78 import org.junit.Test89 @@ -163,6 +164,51 @@163164         val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()164165         assertTrue(error is IllegalStateException)165166         assertTrue(error!!.message!!.contains("CHUNK_COUNT_MISMATCH"))167+    }168+169+    @Test170+    fun `round-trips duress_slot on final chunk`() = runBlocking {171+        val (source, receiver) = memoryPair()172+        val entries = mapOf("id-0" to "cipher-0")173+        val tombstones = mapOf("dead-0" to 42L)174+        val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""175+        sendVaultSnapshot(source, entries, tombstones, duressSlot = duressJson)176+        val snapshot = receiveVaultSnapshot(receiver)177+        assertEquals(entries, snapshot.entries)178+        assertEquals(tombstones, snapshot.tombstones)179+        assertEquals(duressJson, snapshot.duressSlot)180+    }181+182+    @Test183+    fun `duress_slot absent returns null`() = runBlocking {184+        val (source, receiver) = memoryPair()185+        sendVaultSnapshot(source, mapOf("id-0" to "cipher-0"), emptyMap())186+        val snapshot = receiveVaultSnapshot(receiver)187+        assertNull(snapshot.duressSlot)188+    }189+190+    @Test191+    fun `duress_slot rides final chunk only in multi-chunk transfer`() = runBlocking {192+        val (source, receiver) = memoryPair()193+        val big = "x".repeat(200 * 1024)194+        val entries = (0 until 5).associate { "id-$it" to big }195+        val tombstones = mapOf("dead-0" to 42L)196+        val duressJson = """{"version":1,"salt":"ff","nonce":"ee","ciphertext":"dd","tag":"cc"}"""197+        sendVaultSnapshot(source, entries, tombstones, duressSlot = duressJson)198+        val snapshot = receiveVaultSnapshot(receiver)199+        assertEquals(entries, snapshot.entries)200+        assertEquals(tombstones, snapshot.tombstones)201+        assertEquals(duressJson, snapshot.duressSlot)202+    }203+204+    @Test205+    fun `old frame without duress_slot deserializes`() {206+        val oldJson = """{"type":"ADOPT_SNAPSHOT","chunk_index":0,"chunk_count":1,"entries":{"a":"x"},"tombstones":{}}"""207+        val frame = runCatching {208+            AdoptJson.decodeFromString(AdoptionFrame.serializer(), oldJson)209+        }.getOrNull()210+        assertTrue(isAdoptSnapshot(frame))211+        assertTrue((frame as AdoptionFrame.Snapshot).duressSlot == null)166212     }167213 168214 }

view file

app/src/test/java/com/peachpasswords/vault/AdoptDuressSlotTest.kt+143 −0
@@ -0,0 +1,143 @@1+package com.peachpasswords.vault2+3+import kotlinx.coroutines.runBlocking4+import kotlinx.serialization.json.Json5+import org.junit.Assert.assertEquals6+import org.junit.Assert.assertFalse7+import org.junit.Assert.assertNotNull8+import org.junit.Assert.assertNull9+import org.junit.Assert.assertTrue10+import org.junit.Test11+import java.util.Base6412+13+/**14+ * Adoption duress slot install tests. Proves that a browser-shaped duress slot15+ * received through the adoption snapshot installs correctly on Android: the16+ * duress passphrase opens it, invalid input is rejected, and an existing decoy17+ * slot is preserved on failure.18+ */19+class AdoptDuressSlotTest {20+21+    private class MemStorage : VaultStorage {22+        var slotA: StoredVault? = null23+        var slotB: String? = null24+        private var ver: Long = 025+        private var settings = VaultSettings()26+        override fun loadSlotA() = slotA27+        override fun hasVault() = slotA != null28+        override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }29+        override fun loadSlotB() = slotB30+        override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }31+        var decoy: StoredVault? = null32+        override fun loadDecoySlot() = decoy33+        override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }34+        override fun hasDecoySlot() = decoy != null35+        override fun clearDecoySlot() { decoy = null }36+        override fun version() = ver37+        override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }38+        override fun loadSettings() = settings39+        override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }40+        var history = VaultHistory()41+        override fun loadVaultHistory() = history42+        override fun writeVaultHistory(h: VaultHistory): Boolean { history = h; return true }43+        override fun clearVaultHistory() { history = VaultHistory() }44+        override fun clearVault() { slotA = null; slotB = null; decoy = null; ver = 0; settings = VaultSettings(); history = VaultHistory() }45+    }46+47+    private val mainPassword = "correct-horse-battery-staple"48+    private val duressPassword = "open-the-decoy-vault-456"49+50+    private fun hex(bytes: ByteArray): String =51+        bytes.joinToString("") { "%02x".format(it) }52+53+    /**54+     * Convert an Android StoredVault (decoy slot) into a browser-shaped55+     * BrowserSlotA JSON string (hex salt/nonce/ciphertext/tag). Simulates what56+     * the browser sends as the `duress_slot` field in the adoption snapshot.57+     */58+    private fun androidSlotToBrowserJson(slot: StoredVault): String {59+        val salt = Base64.getDecoder().decode(slot.salt)60+        val blob = Base64.getDecoder().decode(slot.blob)61+        val nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)62+        val ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)63+        val ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)64+        val tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)65+        val browserSlot = BrowserSlotA(66+            version = slot.version,67+            salt = hex(salt),68+            nonce = hex(nonce),69+            ciphertext = hex(ct),70+            tag = hex(tag),71+        )72+        return Json.encodeToString(BrowserSlotA.serializer(), browserSlot)73+    }74+75+    @Test76+    fun `installAdoptedDuressSlot installs browser duress that tryUnlockDuress opens`() = runBlocking {77+        // Source: create vault with duress configured.78+        val srcStorage = MemStorage()79+        val src = VaultRepository(srcStorage)80+        src.createVault(mainPassword)81+        src.addEntry(VaultEntry(id = "", title = "RealEntry", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))82+        assertTrue(src.setDuressPassphrase(duressPassword))83+84+        // Extract the duress slot and convert to browser-shaped JSON.85+        val srcDuressSlot = srcStorage.decoy!!86+        val browserDuressJson = androidSlotToBrowserJson(srcDuressSlot)87+88+        // Destination: create a fresh vault (simulating adoption).89+        val dstStorage = MemStorage()90+        val dst = VaultRepository(dstStorage)91+        dst.createVault(mainPassword)92+93+        // Install the browser duress slot.94+        assertTrue("installAdoptedDuressSlot succeeds", dst.installAdoptedDuressSlot(browserDuressJson))95+96+        // Lock and verify duress unlock works.97+        dst.lock()98+        val duress = dst.tryUnlockDuress(duressPassword)99+        assertNotNull("duress passphrase unlocks the adopted duress slot", duress)100+    }101+102+    @Test103+    fun `installAdoptedDuressSlot returns false for invalid JSON`() = runBlocking {104+        val storage = MemStorage()105+        val repo = VaultRepository(storage)106+        repo.createVault(mainPassword)107+108+        assertFalse("invalid JSON returns false", repo.installAdoptedDuressSlot("not json"))109+        assertFalse("empty string returns false", repo.installAdoptedDuressSlot(""))110+        assertFalse("null-ish string returns false", repo.installAdoptedDuressSlot("null"))111+    }112+113+    @Test114+    fun `installAdoptedDuressSlot returns false for non-browser-slot JSON`() = runBlocking {115+        val storage = MemStorage()116+        val repo = VaultRepository(storage)117+        repo.createVault(mainPassword)118+119+        // Valid JSON but not a browser slot shape.120+        assertFalse("non-browser JSON returns false",121+            repo.installAdoptedDuressSlot("""{"foo":"bar"}"""))122+    }123+124+    @Test125+    fun `installAdoptedDuressSlot preserves existing decoy on failure`() = runBlocking {126+        val storage = MemStorage()127+        val repo = VaultRepository(storage)128+        repo.createVault(mainPassword)129+        assertTrue(repo.setDuressPassphrase(duressPassword))130+131+        // Capture the existing decoy slot.132+        val existingDecoy = storage.decoy!!133+134+        // Attempt to install invalid duress slot.135+        assertFalse(repo.installAdoptedDuressSlot("invalid"))136+137+        // Existing decoy is unchanged.138+        assertEquals("existing decoy preserved on failure",139+            existingDecoy.blob, storage.decoy!!.blob)140+        assertEquals("existing decoy salt preserved",141+            existingDecoy.salt, storage.decoy!!.salt)142+    }143+}

view file