ab8531a9baeb

fix: Serialize duress slot wire JSON with explicit version 1 (v2.9.7)

GPT-5.6-Sol HIGH finding from the v2.9.6 boundary review: Android's androidDuressSlotToBrowserJson was using BrowserSlotJson.encodeToString against a data class with plus the module's encodeDefaults = false, so the wire JSON never carried . The browser's hasValidVaultSchema strictly requires and silently dropped Android-origin duress slots on adoption. Also fixed a misleading comment in VaultAdopt.kt that claimed snapshot_capable was always serialized explicitly, when Android omits the false default. Root cause: kotlinx-serialization's encodeDefaults = false drops default field values from the wire. The browser parses with strict validation; an omitted field is the same as a wrong value to it. Solution: Build the wire JSON via JsonObject/JsonPrimitive so version: 1 is always serialized. Also added ctAndTag to the zero-in-finally block. Added a unit test that asserts the wire JSON contains "version":1 and round-trips through BrowserSlot.parse. Changes: - vault/VaultRepository.kt: rewrite androidDuressSlotToBrowserJson to build JsonObject with explicit version: 1; add ctAndTag to finally-zero. - sync/VaultAdopt.kt: rewrite the misleading always-send-explicit comment to match actual wire behavior (Android omits false; browser explicit). - vault/AdoptDuressSlotTest.kt: new test exportDuressSlotForAdoption JSON includes explicit version 1 for browser strict check — asserts JSON contains "version":1 and decodes via BrowserSlot.parse. - VERSION: 2.9.6 -> 2.9.7. app/build.gradle: versionCode 240152 -> 240153.

time
2026-07-30 16:16 UTC · 2 months ago
machine
koh2
parent
e5993569bbf6
5 files VERSION+1 −1app/build.gradle+1 −1app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+5 −3app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+16 −9app/src/test/java/com/peachpasswords/vault/AdoptDuressSlotTest.kt+24 −0
VERSION+1 −1
@@ -1 +1 @@1-2.9.61+2.9.7

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 24015235+        versionCode 2401533636         versionName semanticVersion3737 3838         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

view file

app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+5 −3
@@ -56,9 +56,11 @@5656         val source: Source,5757         // SOURCE echoes receiver's ADOPT_REQUEST.snapshot_capable so the receiver5858         // can skip its 15s inline-snapshot wait when the source has nothing to send.59-        // Always sent as explicit true or false (never omitted) so receivers can rely60-        // on the field's presence as a deliberate answer. Pre-C sources default to61-        // false here and receivers fall back to the sync pipeline immediately.59+        // Default false is OMITTED on the wire (encodeDefaults = false) — the60+        // browser always serializes explicit true OR false via JSON.stringify, but61+        // Android only serializes true; both receivers default to "skip wait" when62+        // the field is absent, so the divergence is invisible to the gate.63+        // Pre-C sources never set the field and decode to false here.6264         @SerialName("snapshot_capable") val snapshotCapable: Boolean = false,6365     ) : AdoptionFrame()6466 

view file

app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+16 −9
@@ -1004,30 +1004,37 @@10041004      * byte arrays are zeroed in finally.10051005      */10061006     private fun androidDuressSlotToBrowserJson(slot: StoredVault): String {1007+        // Build the wire JSON directly with JsonObject so `version: 1` is ALWAYS1008+        // serialized — kotlinx-serialization's encodeDefaults = false would1009+        // otherwise omit BrowserSlotA.version (its Kotlin default), and the1010+        // browser's hasValidVaultSchema strictly requires `r.version === 1`,1011+        // silently dropping the duress slot on Android→browser adoptions.10071012         var salt: ByteArray? = null10081013         var blob: ByteArray? = null10091014         var nonce: ByteArray? = null1015+        var ctAndTag: ByteArray? = null10101016         var ct: ByteArray? = null10111017         var tag: ByteArray? = null10121018         try {10131019             salt = unb64(slot.salt)10141020             blob = unb64(slot.blob)10151021             nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)1016-            val ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)1022+            ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)10171023             ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)10181024             tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)1019-            val browserSlot = BrowserSlotA(1020-                version = slot.version,1021-                salt = salt.joinToString("") { "%02x".format(it) },1022-                nonce = nonce.joinToString("") { "%02x".format(it) },1023-                ciphertext = ct.joinToString("") { "%02x".format(it) },1024-                tag = tag.joinToString("") { "%02x".format(it) },1025-            )1026-            return BrowserSlotJson.encodeToString(BrowserSlotA.serializer(), browserSlot)1025+            val obj = JsonObject(mapOf(1026+                "version" to JsonPrimitive(1),1027+                "salt" to JsonPrimitive(salt.joinToString("") { "%02x".format(it) }),1028+                "nonce" to JsonPrimitive(nonce.joinToString("") { "%02x".format(it) }),1029+                "ciphertext" to JsonPrimitive(ct.joinToString("") { "%02x".format(it) }),1030+                "tag" to JsonPrimitive(tag.joinToString("") { "%02x".format(it) }),1031+            ))1032+            return obj.toString()10271033         } finally {10281034             salt?.let(VaultCrypto::zero)10291035             blob?.let(VaultCrypto::zero)10301036             nonce?.let(VaultCrypto::zero)1037+            ctAndTag?.let(VaultCrypto::zero)10311038             ct?.let(VaultCrypto::zero)10321039             tag?.let(VaultCrypto::zero)10331040         }

view file

app/src/test/java/com/peachpasswords/vault/AdoptDuressSlotTest.kt+24 −0
@@ -181,4 +181,28 @@181181         // No vault created.182182         assertNull("returns null when no vault exists", repo.exportDuressSlotForAdoption())183183     }184+185+    @Test186+    fun `exportDuressSlotForAdoption JSON includes explicit version 1 for browser strict check`() = runBlocking {187+        // Cross-platform contract: the browser's hasValidVaultSchema requires188+        // `r.version === 1`. kotlinx-serialization's encodeDefaults = false189+        // would otherwise omit BrowserSlotA.version (its Kotlin default); the190+        // export builds the JSON via JsonObject so the field is always present.191+        val src = VaultRepository(MemStorage())192+        src.createVault(mainPassword)193+        src.addEntry(VaultEntry(id = "", title = "RealEntry", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))194+        assertTrue(src.setDuressPassphrase(duressPassword))195+196+        val exportedJson = src.exportDuressSlotForAdoption()197+        assertNotNull("export returns JSON", exportedJson)198+        // The exact wire substring the browser parses.199+        assertTrue(200+            "wire JSON must include \"version\":1 (browser's strict hasValidVaultSchema rejects missing/other)",201+            exportedJson!!.contains("\"version\":1"),202+        )203+        // Decoding it back via BrowserSlotA must succeed.204+        val parsed = BrowserSlot.parse(exportedJson)205+        assertNotNull("BrowserSlot.parse decodes the exported JSON", parsed)206+        assertEquals("decoded version is 1", 1, parsed!!.version)207+    }184208 }

view file