1package com.peachpasswords.vault23import kotlinx.coroutines.runBlocking4import kotlinx.serialization.json.Json5import kotlinx.serialization.json.JsonPrimitive6import kotlinx.serialization.json.buildJsonObject7import org.junit.Assert.assertEquals8import org.junit.Assert.assertFalse9import org.junit.Assert.assertNotEquals10import org.junit.Assert.assertNotNull11import org.junit.Assert.assertNull12import org.junit.Assert.assertTrue13import org.junit.Test14import java.util.Base64151617181920212223class DuressCodexRoundTripTest {2425 private class MemStorage : VaultStorage {26 var slotA: StoredVault? = null27 var slotB: String? = null28 private var ver: Long = 029 private var settings = VaultSettings()30 override fun loadSlotA() = slotA31 override fun hasVault() = slotA != null32 override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }33 override fun loadSlotB() = slotB34 override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }35 var decoy: StoredVault? = null36 override fun loadDecoySlot() = decoy37 override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }38 override fun hasDecoySlot() = decoy != null39 override fun clearDecoySlot() { decoy = null }40 override fun version() = ver41 override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }42 override fun loadSettings() = settings43 override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }44 var history = VaultHistory()45 override fun loadVaultHistory() = history46 override fun writeVaultHistory(h: VaultHistory): Boolean { history = h; return true }47 override fun clearVaultHistory() { history = VaultHistory() }48 override fun clearVault(): Boolean { slotA = null; slotB = null; decoy = null; ver = 0; settings = VaultSettings(); history = VaultHistory(); return true }49 }5051 private val mainPassword = "correct-horse-battery-staple"52 private val duressPassword = "open-the-decoy-vault-456"53 private val wrongPassword = "totally-wrong-password"5455 private fun hex(bytes: ByteArray): String =56 bytes.joinToString("") { "%02x".format(it) }5758 59606162636465 private fun androidSlotToBrowserJson(slot: StoredVault): String {66 val salt = Base64.getDecoder().decode(slot.salt)67 val blob = Base64.getDecoder().decode(slot.blob)68 val nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)69 val ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)70 71 val ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)72 val tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)73 val browserSlot = BrowserSlotA(74 version = slot.version,75 salt = hex(salt),76 nonce = hex(nonce),77 ciphertext = hex(ct),78 tag = hex(tag),79 )80 return Json.encodeToString(BrowserSlotA.serializer(), browserSlot)81 }8283 8485868788 private fun buildBrowserCodex(src: VaultRepository): String {89 val androidBlob = src.codexVaultBlobBase64()!!90 val wrapper = String(Base64.getUrlDecoder().decode(androidBlob), Charsets.UTF_8)91 val parsed = Json.parseToJsonElement(wrapper).asJsonObject()92 val slotAJson = parsed["a"].toString()93 val slotBJon = parsed["b"].toString()94 val slotA = VaultCodec.decodeSlot(slotAJson)!!95 val slotB = VaultCodec.decodeSlot(slotBJon)!!96 val browserA = androidSlotToBrowserJson(slotA)97 val browserB = androidSlotToBrowserJson(slotB)98 val browserWrapper = buildJsonObject {99 put("a", JsonPrimitive(browserA))100 put("b", JsonPrimitive(browserB))101 }.toString()102 return Base64.getUrlEncoder().withoutPadding()103 .encodeToString(browserWrapper.toByteArray(Charsets.UTF_8))104 }105106 private fun kotlinx.serialization.json.JsonElement.asJsonObject() =107 this as kotlinx.serialization.json.JsonObject108109 110111112113 @Test fun browser_codex_with_duress_restores_and_unlocks_both_slots() = runBlocking {114 115 val srcStorage = MemStorage()116 val src = VaultRepository(srcStorage)117 src.createVault(mainPassword)118 src.addEntry(VaultEntry(id = "", title = "RealEntry", type = "login", password = "secret", createdAt = 0, modifiedAt = 0))119 assertTrue("duress passphrase configured", src.setDuressPassphrase(duressPassword))120 121 src.lock()122 val srcDuress = src.tryUnlockDuress(duressPassword)123 assertNotNull("source duress unlock works", srcDuress)124 src.discardDuressUnlock(srcDuress!!)125 assertEquals(UnlockResult.Success, src.unlock(mainPassword))126127 128 val browserCodex = buildBrowserCodex(src)129130 131 val dstStorage = MemStorage()132 val dst = VaultRepository(dstStorage)133134 135 assertEquals("real password restores slot A",136 UnlockResult.Success, dst.restoreFromCodex(browserCodex, mainPassword))137 assertTrue("entries present after restore", dst.getEntries().any { it.title == "RealEntry" })138139 140 dst.lock()141142 143 val duress = dst.tryUnlockDuress(duressPassword)144 assertNotNull("duress passphrase unlocks the restored duress slot", duress)145 assertEquals(UnlockResult.Success, dst.commitDuressUnlock(duress!!))146 assertTrue("decoy session is active", dst.isUnlocked.value)147 assertEquals("decoy vault has no real entries", 0, dst.getEntries().size)148149 150 dst.lock()151 assertNull("wrong password does NOT unlock duress slot",152 dst.tryUnlockDuress(wrongPassword))153 assertNull("wrong password does NOT unlock slot A either",154 dst.tryUnlockSlotA(wrongPassword))155 }156157 158159160161 @Test fun no_duress_codex_produces_size_matched_fake() = runBlocking {162 163 val srcStorage = MemStorage()164 val src = VaultRepository(srcStorage)165 src.createVault(mainPassword)166 src.addEntry(VaultEntry(id = "", title = "OnlyReal", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))167168 169 val androidBlob = src.codexVaultBlobBase64()!!170 val wrapper = String(Base64.getUrlDecoder().decode(androidBlob), Charsets.UTF_8)171 val parsed = Json.parseToJsonElement(wrapper).asJsonObject()172 val slotAJson = parsed["a"].toString()173 val slotA = VaultCodec.decodeSlot(slotAJson)!!174 val browserA = androidSlotToBrowserJson(slotA)175 val browserWrapper = buildJsonObject {176 put("a", JsonPrimitive(browserA))177 put("b", kotlinx.serialization.json.JsonNull)178 }.toString()179 val codex = Base64.getUrlEncoder().withoutPadding()180 .encodeToString(browserWrapper.toByteArray(Charsets.UTF_8))181182 183 val dstStorage = MemStorage()184 val dst = VaultRepository(dstStorage)185 assertEquals(UnlockResult.Success, dst.restoreFromCodex(codex, mainPassword))186187 188 assertNotNull("fake duress slot written", dstStorage.decoy)189 val slotABlobLen = Base64.getDecoder().decode(dstStorage.slotA!!.blob).size190 val duressBlobLen = Base64.getDecoder().decode(dstStorage.decoy!!.blob).size191 assertEquals("fake duress blob is size-matched to slot A", slotABlobLen, duressBlobLen)192193 194 dst.lock()195 assertNull("fake duress cannot be unlocked", dst.tryUnlockDuress(mainPassword))196 assertNull("fake duress cannot be unlocked with any password", dst.tryUnlockDuress("anything"))197 }198199 200201202203204 @Test fun android_codex_with_duress_round_trips() = runBlocking {205 val srcStorage = MemStorage()206 val src = VaultRepository(srcStorage)207 src.createVault(mainPassword)208 src.addEntry(VaultEntry(id = "", title = "AndroidOnly", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))209 assertTrue(src.setDuressPassphrase(duressPassword))210211 val codex = src.codexVaultBlobBase64()!!212213 val dstStorage = MemStorage()214 val dst = VaultRepository(dstStorage)215 assertEquals(UnlockResult.Success, dst.restoreFromCodex(codex, mainPassword))216 assertTrue(dst.getEntries().any { it.title == "AndroidOnly" })217218 dst.lock()219 val duress = dst.tryUnlockDuress(duressPassword)220 assertNotNull("Android-to-Android duress round-trip works", duress)221 assertEquals(UnlockResult.Success, dst.commitDuressUnlock(duress!!))222 assertEquals(0, dst.getEntries().size)223 }224}