1package com.peachpasswords.vault23import kotlinx.coroutines.runBlocking4import kotlinx.serialization.json.Json5import org.junit.Assert.assertEquals6import org.junit.Assert.assertFalse7import org.junit.Assert.assertNotNull8import org.junit.Assert.assertNull9import org.junit.Assert.assertTrue10import org.junit.Test11import java.util.Base641213141516171819class AdoptDuressSlotTest {2021 private class MemStorage : VaultStorage {22 var slotA: StoredVault? = null23 var slotB: String? = null24 private var ver: Long = 025 private var settings = VaultSettings()26 override fun loadSlotA() = slotA27 override fun hasVault() = slotA != null28 override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }29 override fun loadSlotB() = slotB30 override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }31 var decoy: StoredVault? = null32 override fun loadDecoySlot() = decoy33 override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }34 override fun hasDecoySlot() = decoy != null35 override fun clearDecoySlot() { decoy = null }36 override fun version() = ver37 override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }38 override fun loadSettings() = settings39 override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }40 var history = VaultHistory()41 override fun loadVaultHistory() = history42 override fun writeVaultHistory(h: VaultHistory): Boolean { history = h; return true }43 override fun clearVaultHistory() { history = VaultHistory() }44 override fun clearVault(): Boolean { slotA = null; slotB = null; decoy = null; ver = 0; settings = VaultSettings(); history = VaultHistory(); return true }45 }4647 private val mainPassword = "correct-horse-battery-staple"48 private val duressPassword = "open-the-decoy-vault-456"4950 private fun hex(bytes: ByteArray): String =51 bytes.joinToString("") { "%02x".format(it) }5253 5455565758 private fun androidSlotToBrowserJson(slot: StoredVault): String {59 val salt = Base64.getDecoder().decode(slot.salt)60 val blob = Base64.getDecoder().decode(slot.blob)61 val nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)62 val ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)63 val ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)64 val tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)65 val browserSlot = BrowserSlotA(66 version = 1,67 salt = hex(salt),68 nonce = hex(nonce),69 ciphertext = hex(ct),70 tag = hex(tag),71 )72 return Json.encodeToString(BrowserSlotA.serializer(), browserSlot)73 }7475 @Test76 fun `installAdoptedDuressSlot installs browser duress that tryUnlockDuress opens`() = runBlocking {77 78 val srcStorage = MemStorage()79 val src = VaultRepository(srcStorage)80 src.createVault(mainPassword)81 src.addEntry(VaultEntry(id = "", title = "RealEntry", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))82 assertTrue(src.setDuressPassphrase(duressPassword))8384 85 val srcDuressSlot = srcStorage.decoy!!86 val browserDuressJson = androidSlotToBrowserJson(srcDuressSlot)8788 89 val dstStorage = MemStorage()90 val dst = VaultRepository(dstStorage)91 dst.createVault(mainPassword)9293 94 assertTrue("installAdoptedDuressSlot succeeds", dst.installAdoptedDuressSlot(browserDuressJson))9596 97 dst.lock()98 val duress = dst.tryUnlockDuress(duressPassword)99 assertNotNull("duress passphrase unlocks the adopted duress slot", duress)100 }101102 @Test103 fun `installAdoptedDuressSlot returns false for invalid JSON`() = runBlocking {104 val storage = MemStorage()105 val repo = VaultRepository(storage)106 repo.createVault(mainPassword)107108 assertFalse("invalid JSON returns false", repo.installAdoptedDuressSlot("not json"))109 assertFalse("empty string returns false", repo.installAdoptedDuressSlot(""))110 assertFalse("null-ish string returns false", repo.installAdoptedDuressSlot("null"))111 }112113 @Test114 fun `installAdoptedDuressSlot returns false for non-browser-slot JSON`() = runBlocking {115 val storage = MemStorage()116 val repo = VaultRepository(storage)117 repo.createVault(mainPassword)118119 120 assertFalse("non-browser JSON returns false",121 repo.installAdoptedDuressSlot("""{"foo":"bar"}"""))122 }123124 @Test125 fun `installAdoptedDuressSlot preserves existing decoy on failure`() = runBlocking {126 val storage = MemStorage()127 val repo = VaultRepository(storage)128 repo.createVault(mainPassword)129 assertTrue(repo.setDuressPassphrase(duressPassword))130131 132 val existingDecoy = storage.decoy!!133134 135 assertFalse(repo.installAdoptedDuressSlot("invalid"))136137 138 assertEquals("existing decoy preserved on failure",139 existingDecoy.blob, storage.decoy!!.blob)140 assertEquals("existing decoy salt preserved",141 existingDecoy.salt, storage.decoy!!.salt)142 }143144 @Test145 fun `exportDuressSlotForAdoption round trip with installAdoptedDuressSlot`() = runBlocking {146 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))151152 153 val exportedJson = src.exportDuressSlotForAdoption()154 assertNotNull("exportDuressSlotForAdoption returns non-null when duress configured", exportedJson)155156 157 val dst = VaultRepository(MemStorage())158 dst.createVault(mainPassword)159160 161 assertTrue("installAdoptedDuressSlot succeeds with exported JSON",162 dst.installAdoptedDuressSlot(exportedJson!!))163164 165 dst.lock()166 val duress = dst.tryUnlockDuress(duressPassword)167 assertNotNull("duress passphrase unlocks the adopted duress slot via round trip", duress)168 }169170 @Test171 fun `exportDuressSlotForAdoption returns null when duress not configured`() = runBlocking {172 val repo = VaultRepository(MemStorage())173 repo.createVault(mainPassword)174 175 assertNull("returns null when duress not configured", repo.exportDuressSlotForAdoption())176 }177178 @Test179 fun `exportDuressSlotForAdoption returns null when no vault exists`() = runBlocking {180 val repo = VaultRepository(MemStorage())181 182 assertNull("returns null when no vault exists", repo.exportDuressSlotForAdoption())183 }184185 @Test186 fun `exportDuressSlotForAdoption JSON includes explicit version 1 for browser strict check`() = runBlocking {187 188 189 190 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))195196 val exportedJson = src.exportDuressSlotForAdoption()197 assertNotNull("export returns JSON", exportedJson)198 199 assertTrue(200 "wire JSON must include \"version\":1 (browser's strict hasValidVaultSchema rejects missing/other)",201 exportedJson!!.contains("\"version\":1"),202 )203 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 }208}