ff6562aacdb4
fix: Install the browser duress slot from the adoption snapshot (v2.9.3)
- time
- 2026-07-30 12:12 UTC · 2 months ago
- machine
- koh2
- parent
- cb62379d810a
↓ peach-android-ff6562aa.tar.gzatlas at this save
files changed7 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 −0app/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 {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 =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) {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 fakeapp/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 }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+}