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
↓ peach-android-942f3c20.tar.gzatlas at this save
files changed9 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 −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 24014935+ versionCode 2401503636 versionName semanticVersion3737 3838 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"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()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()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}\$")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) {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) {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 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 }