ab8531a9baeb
fix: Serialize duress slot wire JSON with explicit version 1 (v2.9.7)
- time
- 2026-07-30 16:16 UTC · 2 months ago
- machine
- koh2
- parent
- e5993569bbf6
↓ peach-android-ab8531a9.tar.gzatlas at this save
files changed5 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 −0app/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"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 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 }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 }