942f3c20cdcf

feat: Send the sealed duress slot as adoption-snapshot source (v2.9.4)

time
2026-07-30 13:32 UTC · 2 months ago
machine
koh2
parent
ff6562aacdb4
9 files VERSION+1 −1app/build.gradle+1 −1app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+3 −1app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+3 −1app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+2 −0app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+3 −0app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+3 −0app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+56 −0app/src/test/java/com/peachpasswords/vault/AdoptDuressSlotTest.kt+41 −0
VERSION+1 −1
@@ -1 +1 @@1-2.9.31+2.9.4

view file

app/build.gradle+1 −1
@@ -32,7 +32,7 @@3232         minSdk 263333         targetSdk 353434         // Keep the Android code in Gradle; the semantic name comes from root VERSION.35-        versionCode 24014935+        versionCode 2401503636         versionName semanticVersion3737 3838         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+3 −1
@@ -205,6 +205,7 @@205205             override suspend fun vaultSnapshot() = sourceDeps.vaultSnapshot()206206             override suspend fun outgoingDeviceManifest() = sourceDeps.outgoingDeviceManifest()207207             override suspend fun markSynced(deviceId: String) = sourceDeps.markSynced(deviceId)208+            override suspend fun duressSlotJson() = sourceDeps.duressSlotJson()208209         }209210         val adopted = grantAdoption(channel, request, boundDeps) ?: return null210211         // If the receiver advertised snapshot support, send the vault inline.@@ -212,7 +213,8 @@212213             try {213214                 val snapshot = boundDeps.vaultSnapshot()214215                 if (snapshot != null) {215-                    sendVaultSnapshot(channel, snapshot.first, snapshot.second)216+                    val duressSlot = runCatching { boundDeps.duressSlotJson() }.getOrNull()217+                    sendVaultSnapshot(channel, snapshot.first, snapshot.second, duressSlot)216218                     val applied = receiveFrame(channel)217219                     if (applied is AdoptionFrame.SnapshotApplied) {218220                         val manifest = boundDeps.outgoingDeviceManifest()

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+3 −1
@@ -323,6 +323,7 @@323323             override suspend fun vaultSnapshot() = sourceDeps.vaultSnapshot()324324             override suspend fun outgoingDeviceManifest() = sourceDeps.outgoingDeviceManifest()325325             override suspend fun markSynced(deviceId: String) = sourceDeps.markSynced(deviceId)326+            override suspend fun duressSlotJson() = sourceDeps.duressSlotJson()326327         }327328         val adopted = grantAdoption(channel, request, boundDeps) ?: return null328329         // If the receiver advertised snapshot support, send the vault inline.@@ -330,7 +331,8 @@330331             try {331332                 val snapshot = boundDeps.vaultSnapshot()332333                 if (snapshot != null) {333-                    sendVaultSnapshot(channel, snapshot.first, snapshot.second)334+                    val duressSlot = runCatching { boundDeps.duressSlotJson() }.getOrNull()335+                    sendVaultSnapshot(channel, snapshot.first, snapshot.second, duressSlot)334336                     val applied = receiveFrame(channel)335337                     if (applied is AdoptionFrame.SnapshotApplied) {336338                         val manifest = boundDeps.outgoingDeviceManifest()

view file

app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+2 −0
@@ -217,6 +217,8 @@217217     suspend fun outgoingDeviceManifest(): SignedDeviceManifest? = null218218     /** Mark the paired device as synced after a successful snapshot. */219219     suspend fun markSynced(deviceId: String) {}220+    /** Browser-shaped sealed duress slot JSON for the snapshot, if duress is configured. */221+    suspend fun duressSlotJson(): String? = null220222 }221223 222224 private val Hex64 = Regex("^[0-9a-f]{64}\$")

view file

app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+3 −0
@@ -400,6 +400,9 @@400400             override suspend fun markSynced(deviceId: String) {401401                 devices.updateLastSync(deviceId)402402             }403+404+            override suspend fun duressSlotJson(): String? =405+                vault.exportDuressSlotForAdoption()403406         }404407 405408     fun setBackgroundSync(enabled: Boolean) {

view file

app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+3 −0
@@ -223,6 +223,9 @@223223             override suspend fun markSynced(deviceId: String) {224224                 deviceStore.updateLastSync(deviceId)225225             }226+227+            override suspend fun duressSlotJson(): String? =228+                vault.exportDuressSlotForAdoption()226229         }227230 228231     fun setBackgroundSync(enabled: Boolean) {

view file

app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+56 −0
@@ -26,6 +26,9 @@2626 /** Lenient parser for the Codex vault-blob wrapper only. */2727 private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2828 29+/** Omits null optional fields — used for browser-shaped duress slot export. */30+private val BrowserSlotJson = Json { encodeDefaults = false }31+2932 private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)3033 private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)3134 private fun unhex(s: String): ByteArray {@@ -983,6 +986,59 @@983986             // codec/copy exception) as well as any early return after key derivation.984987             key?.let(VaultCrypto::zero)985988             basePlaintext?.let(VaultCrypto::zero)989+        }990+    }991+992+993+    /**994+     * Export the decoy slot as browser-shaped JSON for adoption snapshots.995+     *996+     * Returns the sealed slot as `{version, salt, nonce, ciphertext, tag}` all997+     * lowercase hex — identical to the browser's `duress_slot` wire format — so998+     * any receiver (browser or Android) can install it via the existing999+     * passthrough. Returns null when duress is not configured, no decoy exists,1000+     * or any conversion failure occurs. Never decrypts.1001+     */1002+    suspend fun exportDuressSlotForAdoption(): String? = mutex.withLock {1003+        if (!duressConfigured) return null1004+        val slot = store.loadDecoySlot() ?: return null1005+        return runCatching { androidDuressSlotToBrowserJson(slot) }.getOrNull()1006+    }1007+1008+    /**1009+     * Convert an Android [StoredVault] decoy slot to browser-shaped JSON.1010+     *1011+     * Mirrors the inverse of [installAdoptedDuressSlot]: base64 salt/blob are1012+     * split into lowercase hex fields matching [BrowserSlotA]. Intermediate1013+     * byte arrays are zeroed in finally.1014+     */1015+    private fun androidDuressSlotToBrowserJson(slot: StoredVault): String {1016+        var salt: ByteArray? = null1017+        var blob: ByteArray? = null1018+        var nonce: ByteArray? = null1019+        var ct: ByteArray? = null1020+        var tag: ByteArray? = null1021+        try {1022+            salt = unb64(slot.salt)1023+            blob = unb64(slot.blob)1024+            nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)1025+            val ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)1026+            ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)1027+            tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)1028+            val browserSlot = BrowserSlotA(1029+                version = slot.version,1030+                salt = salt.joinToString("") { "%02x".format(it) },1031+                nonce = nonce.joinToString("") { "%02x".format(it) },1032+                ciphertext = ct.joinToString("") { "%02x".format(it) },1033+                tag = tag.joinToString("") { "%02x".format(it) },1034+            )1035+            return BrowserSlotJson.encodeToString(BrowserSlotA.serializer(), browserSlot)1036+        } finally {1037+            salt?.let(VaultCrypto::zero)1038+            blob?.let(VaultCrypto::zero)1039+            nonce?.let(VaultCrypto::zero)1040+            ct?.let(VaultCrypto::zero)1041+            tag?.let(VaultCrypto::zero)9861042         }9871043     }9881044 

view file

app/src/test/java/com/peachpasswords/vault/AdoptDuressSlotTest.kt+41 −0
@@ -140,4 +140,45 @@140140         assertEquals("existing decoy salt preserved",141141             existingDecoy.salt, storage.decoy!!.salt)142142     }143+144+    @Test145+    fun `exportDuressSlotForAdoption round trip with installAdoptedDuressSlot`() = runBlocking {146+        // Source: create vault with duress configured.147+        val src = VaultRepository(MemStorage())148+        src.createVault(mainPassword)149+        src.addEntry(VaultEntry(id = "", title = "RealEntry", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))150+        assertTrue(src.setDuressPassphrase(duressPassword))151+152+        // Export via the production method (Android -> browser shape).153+        val exportedJson = src.exportDuressSlotForAdoption()154+        assertNotNull("exportDuressSlotForAdoption returns non-null when duress configured", exportedJson)155+156+        // Destination: create a fresh vault (simulating adoption).157+        val dst = VaultRepository(MemStorage())158+        dst.createVault(mainPassword)159+160+        // Install the exported browser duress slot.161+        assertTrue("installAdoptedDuressSlot succeeds with exported JSON",162+            dst.installAdoptedDuressSlot(exportedJson!!))163+164+        // Lock and verify duress unlock works.165+        dst.lock()166+        val duress = dst.tryUnlockDuress(duressPassword)167+        assertNotNull("duress passphrase unlocks the adopted duress slot via round trip", duress)168+    }169+170+    @Test171+    fun `exportDuressSlotForAdoption returns null when duress not configured`() = runBlocking {172+        val repo = VaultRepository(MemStorage())173+        repo.createVault(mainPassword)174+        // Duress not configured — decoy slot may exist as noise but duressConfigured is false.175+        assertNull("returns null when duress not configured", repo.exportDuressSlotForAdoption())176+    }177+178+    @Test179+    fun `exportDuressSlotForAdoption returns null when no vault exists`() = runBlocking {180+        val repo = VaultRepository(MemStorage())181+        // No vault created.182+        assertNull("returns null when no vault exists", repo.exportDuressSlotForAdoption())183+    }143184 }

view file