1package com.peachpasswords.vault23import kotlinx.coroutines.runBlocking4import kotlinx.serialization.json.JsonPrimitive5import kotlinx.serialization.json.buildJsonObject6import org.junit.Assert.assertEquals7import org.junit.Assert.assertFalse8import org.junit.Assert.assertNotEquals9import org.junit.Assert.assertNotNull10import org.junit.Assert.assertNull11import org.junit.Assert.assertTrue12import org.junit.Test13import java.util.Base64141516private class DuressFakeStorage : VaultStorage {17 var slotA: StoredVault? = null18 var slotB: String? = null19 private var ver: Long = 020 private var settings = VaultSettings()21 override fun loadSlotA() = slotA22 override fun hasVault() = slotA != null23 override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }24 override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }25 var decoy: StoredVault? = null26 override fun loadDecoySlot() = decoy27 override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }28 override fun hasDecoySlot() = decoy != null29 override fun clearDecoySlot() { decoy = null }30 override fun version() = ver31 override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }32 override fun loadSettings() = settings33 override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }34 var history = VaultHistory()35 override fun loadVaultHistory() = history36 override fun writeVaultHistory(h: VaultHistory): Boolean { history = h; return true }37 override fun clearVault() { slotA = null; slotB = null; decoy = null; ver = 0; settings = VaultSettings(); history = VaultHistory() }38}39404142434445class DuressSlotBTest {46 private val real = "the-real-password-123"47 private val duressPass = "open-the-decoy-456"4849 private fun configuredRepo(): VaultRepository = runBlocking {50 val repo = VaultRepository(DuressFakeStorage())51 repo.createVault(real)52 repo.addEntry(VaultEntry(id = "", title = "Secret", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))53 assertTrue("duress passphrase sealed into slot B", repo.setDuressPassphrase(duressPass))54 repo.lock()55 repo56 }5758 @Test fun real_password_opens_slot_a_only() = runBlocking {59 val repo = configuredRepo()60 assertNotNull("real password opens slot A", repo.tryUnlockSlotA(real))61 assertNull("real password does NOT open the duress slot", repo.tryUnlockDuress(real))62 }6364 @Test fun duress_passphrase_opens_decoy_only() = runBlocking {65 val repo = configuredRepo()66 assertNull("duress passphrase does NOT open slot A", repo.tryUnlockSlotA(duressPass))67 val duress = repo.tryUnlockDuress(duressPass)68 assertNotNull("duress passphrase opens the duress slot", duress)69 assertEquals(UnlockResult.Success, repo.commitDuressUnlock(duress!!))70 assertTrue(repo.isUnlocked.value)71 assertEquals("decoy vault is empty — no real entries leak", 0, repo.getEntries().size)72 }7374 @Test fun wrong_password_opens_neither() = runBlocking {75 val repo = configuredRepo()76 assertNull(repo.tryUnlockSlotA("totally-wrong"))77 assertNull(repo.tryUnlockDuress("totally-wrong"))78 }7980 @Test fun duress_passphrase_cannot_equal_real_password() = runBlocking {81 val repo = VaultRepository(DuressFakeStorage())82 repo.createVault(real)83 assertFalse("a duress passphrase matching the real password is rejected", repo.setDuressPassphrase(real))84 }8586 @Test fun clear_duress_replaces_with_fake_of_matching_size() = runBlocking {87 88 89 90 val storage = DuressFakeStorage()91 val repo = VaultRepository(storage)92 repo.createVault(real)93 repo.addEntry(VaultEntry(id = "", title = "Real", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))94 assertTrue(repo.setDuressPassphrase(duressPass))95 assertEquals(UnlockResult.Success, repo.unlock(real))96 val slotALen = storage.slotA!!.blob.length97 val decoyBefore = storage.decoy!!98 assertEquals(slotALen, decoyBefore.blob.length)99 assertTrue(repo.clearDuress())100 assertNotNull("duress record still present after clear", storage.decoy)101 102 assertEquals(slotALen, storage.decoy!!.blob.length)103 104 repo.lock()105 assertNull(repo.tryUnlockDuress(duressPass))106 }107108 @Test fun `HOLE 5 the duress vault has no sync identity and cannot sync`() = runBlocking {109 val repo = configuredRepo()110111 112 assertNotNull(repo.tryUnlockSlotA(real))113 assertEquals(UnlockResult.Success, repo.unlock(real))114 val realVaultId = repo.syncVaultId()115 assertNotNull("the real vault syncs", realVaultId)116 repo.lock()117118 119 120 121 val duress = repo.tryUnlockDuress(duressPass)122 assertNotNull(duress)123 assertEquals(UnlockResult.Success, repo.commitDuressUnlock(duress!!))124 assertTrue(repo.isUnlocked.value)125 assertNull("the duress vault must expose no sync key", repo.syncMasterKey())126 assertNull("the duress vault must expose no vault id", repo.syncVaultId())127128 129 130 val adapter = com.peachpasswords.sync.VaultSyncAdapter(repo, ourDeviceId = "decoy-device")131 var threw = false132 try {133 adapter.computeVaultId()134 } catch (_: Exception) {135 threw = true136 }137 assertTrue("the decoy vault must not produce a vault id for sync", threw)138 }139140 141142 143144145146147148149 @Test fun h04_duress_record_size_matches_slot_a() = runBlocking {150 151 val storage = DuressFakeStorage()152 val repo = VaultRepository(storage)153 repo.createVault(real)154 val slotALen = storage.slotA!!.blob.length155 assertNotNull("duress record exists even with no duress configured (H-04)", storage.decoy)156 assertEquals("fake duress blob length matches slot A blob length", slotALen, storage.decoy!!.blob.length)157158 159 assertTrue(repo.setDuressPassphrase(duressPass))160 assertEquals("real duress blob length matches slot A blob length", slotALen, storage.decoy!!.blob.length)161162 163 164 165 166 167 repo.addEntry(VaultEntry(id = "", title = "Big", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))168 repo.clearDuress()169 val newSlotALen = storage.slotA!!.blob.length170 assertEquals("fake duress re-padded to slot A after slot A grows", newSlotALen, storage.decoy!!.blob.length)171 }172173 174175176177178179 @Test fun h04_snapshot_contains_duress() = runBlocking {180 val storage = DuressFakeStorage()181 val repo = VaultRepository(storage)182 repo.createVault(real)183 val initialDuress = storage.decoy184 assertNotNull(initialDuress)185 186 repo.addEntry(VaultEntry(id = "", title = "T", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))187 val snap = storage.history.snapshots.first()188 assertNotNull("snapshot.vault_d captures the duress slot", snap.vault_d)189 assertEquals("snapshotted duress matches the on-disk duress", initialDuress!!.blob, VaultCodec.decodeSlot(snap.vault_d!!)!!.blob)190 191 assertTrue(repo.restoreVaultSnapshot(snap.id))192 assertNotNull(storage.decoy)193 assertEquals("restored duress matches the snapshot", VaultCodec.encodeSlot(initialDuress), snap.vault_d)194 }195196 197198199200201202203 @Test fun h04_codex_restore_puts_browser_b_into_duress_slot() = runBlocking {204 205 val srcStorage = DuressFakeStorage()206 val src = VaultRepository(srcStorage)207 src.createVault(real)208 src.addEntry(VaultEntry(id = "", title = "Real", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))209 assertTrue(src.setDuressPassphrase(duressPass))210 val srcDuress = srcStorage.decoy!!211212 213 214 215 val androidBlob = src.codexVaultBlobBase64()!!216 val wrapper = String(Base64.getUrlDecoder().decode(androidBlob), Charsets.UTF_8)217 218 219 val slotAJson = balancedBraceAfter(wrapper, "\"a\"")220 val slotDJson = balancedBraceAfter(wrapper, "\"b\"")221 val browserWrapper = buildJsonObject {222 put("a", JsonPrimitive(slotAJson))223 put("b", JsonPrimitive(slotDJson))224 }.toString()225 val browserCodex = Base64.getUrlEncoder().withoutPadding()226 .encodeToString(browserWrapper.toByteArray(Charsets.UTF_8))227228 229 val dstStorage: DuressFakeStorage = DuressFakeStorage()230 val dst: VaultRepository = VaultRepository(dstStorage)231 dst.createVault(real)232233 assertEquals(UnlockResult.Success, dst.restoreFromCodex(browserCodex, real))234235 236 assertNotNull("duress slot populated from Codex 'b'", dstStorage.decoy)237 assertEquals("restored duress blob matches the source duress", srcDuress.blob, dstStorage.decoy!!.blob)238 239 240 241 assertNotEquals("noise slot is not the duress slot", dstStorage.slotB, dstStorage.decoy?.blob)242 }243244 245246247248249250 private fun balancedBraceAfter(wrapper: String, keyLiteral: String): String {251 val openMarker = "${Regex.escape(keyLiteral)}\\s*:\\s*\\{".toRegex().find(wrapper)252 ?: error("key $keyLiteral not found")253 val open = openMarker.range.last254 var depth = 1255 var end = open + 1256 while (end < wrapper.length && depth > 0) {257 when (wrapper[end]) {258 '{' -> depth++259 '}' -> depth--260 }261 if (depth == 0) break262 end++263 }264 return wrapper.substring(open, end + 1)265 }266}