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.assertNotNull9import org.junit.Assert.assertNull10import org.junit.Assert.assertTrue11import org.junit.Test12import java.util.Base64131415private class DuressFakeStorage : VaultStorage {16 var slotA: StoredVault? = null17 var slotB: String? = null18 private var ver: Long = 019 private var settings = VaultSettings()20 override fun loadSlotA() = slotA21 override fun hasVault() = slotA != null22 override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }23 override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }24 var decoy: StoredVault? = null25 override fun loadDecoySlot() = decoy26 override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }27 override fun hasDecoySlot() = decoy != null28 override fun clearDecoySlot() { decoy = null }29 override fun version() = ver30 override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }31 override fun loadSettings() = settings32 override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }33 var history = VaultHistory()34 override fun loadVaultHistory() = history35 override fun writeVaultHistory(h: VaultHistory): Boolean { history = h; return true }36 override fun clearVault() { slotA = null; slotB = null; decoy = null; ver = 0; settings = VaultSettings(); history = VaultHistory() }37}38394041424344class DuressSlotBTest {45 private val real = "the-real-password-123"46 private val duressPass = "open-the-decoy-456"4748 private fun configuredRepo(): VaultRepository = runBlocking {49 val repo = VaultRepository(DuressFakeStorage())50 repo.createVault(real)51 repo.addEntry(VaultEntry(id = "", title = "Secret", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))52 assertTrue("duress passphrase sealed into slot B", repo.setDuressPassphrase(duressPass))53 repo.lock()54 repo55 }5657 @Test fun real_password_opens_slot_a_only() = runBlocking {58 val repo = configuredRepo()59 assertNotNull("real password opens slot A", repo.tryUnlockSlotA(real))60 assertNull("real password does NOT open the duress slot", repo.tryUnlockDuress(real))61 }6263 @Test fun duress_passphrase_opens_decoy_only() = runBlocking {64 val repo = configuredRepo()65 assertNull("duress passphrase does NOT open slot A", repo.tryUnlockSlotA(duressPass))66 val duress = repo.tryUnlockDuress(duressPass)67 assertNotNull("duress passphrase opens the duress slot", duress)68 assertEquals(UnlockResult.Success, repo.commitDuressUnlock(duress!!))69 assertTrue(repo.isUnlocked.value)70 assertEquals("decoy vault is empty — no real entries leak", 0, repo.getEntries().size)71 }7273 @Test fun wrong_password_opens_neither() = runBlocking {74 val repo = configuredRepo()75 assertNull(repo.tryUnlockSlotA("totally-wrong"))76 assertNull(repo.tryUnlockDuress("totally-wrong"))77 }7879 @Test fun duress_passphrase_cannot_equal_real_password() = runBlocking {80 val repo = VaultRepository(DuressFakeStorage())81 repo.createVault(real)82 assertFalse("a duress passphrase matching the real password is rejected", repo.setDuressPassphrase(real))83 }8485 @Test fun clear_duress_replaces_with_fake_of_matching_size() = runBlocking {86 87 88 89 val storage = DuressFakeStorage()90 val repo = VaultRepository(storage)91 repo.createVault(real)92 repo.addEntry(VaultEntry(id = "", title = "Real", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))93 assertTrue(repo.setDuressPassphrase(duressPass))94 assertEquals(UnlockResult.Success, repo.unlock(real))95 val slotALen = storage.slotA!!.blob.length96 val decoyBefore = storage.decoy!!97 assertEquals(slotALen, decoyBefore.blob.length)98 assertTrue(repo.clearDuress())99 assertNotNull("duress record still present after clear", storage.decoy)100 101 assertEquals(slotALen, storage.decoy!!.blob.length)102 103 repo.lock()104 assertNull(repo.tryUnlockDuress(duressPass))105 }106107 @Test fun `HOLE 5 the duress vault has no sync identity and cannot sync`() = runBlocking {108 val repo = configuredRepo()109110 111 assertNotNull(repo.tryUnlockSlotA(real))112 assertEquals(UnlockResult.Success, repo.unlock(real))113 val realVaultId = repo.syncVaultId()114 assertNotNull("the real vault syncs", realVaultId)115 repo.lock()116117 118 119 120 val duress = repo.tryUnlockDuress(duressPass)121 assertNotNull(duress)122 assertEquals(UnlockResult.Success, repo.commitDuressUnlock(duress!!))123 assertTrue(repo.isUnlocked.value)124 assertNull("the duress vault must expose no sync key", repo.syncMasterKey())125 assertNull("the duress vault must expose no vault id", repo.syncVaultId())126127 128 129 val adapter = com.peachpasswords.sync.VaultSyncAdapter(repo, ourDeviceId = "decoy-device")130 var threw = false131 try {132 adapter.computeVaultId()133 } catch (_: Exception) {134 threw = true135 }136 assertTrue("the decoy vault must not produce a vault id for sync", threw)137 }138139 140141 142143144145146147148 @Test fun h04_duress_record_size_matches_slot_a() = runBlocking {149 150 val storage = DuressFakeStorage()151 val repo = VaultRepository(storage)152 repo.createVault(real)153 val slotALen = storage.slotA!!.blob.length154 assertNotNull("duress record exists even with no duress configured (H-04)", storage.decoy)155 assertEquals("fake duress blob length matches slot A blob length", slotALen, storage.decoy!!.blob.length)156157 158 assertTrue(repo.setDuressPassphrase(duressPass))159 assertEquals("real duress blob length matches slot A blob length", slotALen, storage.decoy!!.blob.length)160161 162 163 164 165 166 repo.addEntry(VaultEntry(id = "", title = "Big", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))167 repo.clearDuress()168 val newSlotALen = storage.slotA!!.blob.length169 assertEquals("fake duress re-padded to slot A after slot A grows", newSlotALen, storage.decoy!!.blob.length)170 }171172 173174175176177178 @Test fun h04_snapshot_contains_duress() = runBlocking {179 val storage = DuressFakeStorage()180 val repo = VaultRepository(storage)181 repo.createVault(real)182 val initialDuress = storage.decoy183 assertNotNull(initialDuress)184 185 repo.addEntry(VaultEntry(id = "", title = "T", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))186 val snap = storage.history.snapshots.first()187 assertNotNull("snapshot.vault_d captures the duress slot", snap.vault_d)188 assertEquals("snapshotted duress matches the on-disk duress", initialDuress!!.blob, VaultCodec.decodeSlot(snap.vault_d!!)!!.blob)189 190 assertTrue(repo.restoreVaultSnapshot(snap.id))191 assertNotNull(storage.decoy)192 assertEquals("restored duress matches the snapshot", VaultCodec.encodeSlot(initialDuress), snap.vault_d)193 }194195 196197198199200201202 @Test fun h04_codex_restore_puts_browser_b_into_duress_slot() = runBlocking {203 204 val srcStorage = DuressFakeStorage()205 val src = VaultRepository(srcStorage)206 src.createVault(real)207 src.addEntry(VaultEntry(id = "", title = "Real", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))208 assertTrue(src.setDuressPassphrase(duressPass))209 val srcDuress = srcStorage.decoy!!210211 212 213 214 val androidBlob = src.codexVaultBlobBase64()!!215 val wrapper = String(Base64.getUrlDecoder().decode(androidBlob), Charsets.UTF_8)216 val slotAJson = Regex("\"a\"\\s*:\\s*(\\{.*\\})").find(wrapper)!!.groupValues[1]217 val slotDJson = Regex("\"b\"\\s*:\\s*(\\{.*\\})").find(wrapper)!!.groupValues[1]218 val browserWrapper = buildJsonObject {219 put("a", JsonPrimitive(slotAJson))220 put("b", JsonPrimitive(slotDJson))221 }.toString()222 val browserCodex = Base64.getUrlEncoder().withoutPadding()223 .encodeToString(browserWrapper.toByteArray(Charsets.UTF_8))224225 226 val dstStorage = DuressFakeStorage()227 val dst = VaultRepository(dstStorage)228 dst.createVault(real)229 230 dstStorage.slotB = "noise-before-restore"231232 assertEquals(UnlockResult.Success, dst.restoreFromCodex(browserCodex, real))233234 235 assertEquals("noise slot is local-only and untouched by Codex restore", "noise-before-restore", dstStorage.slotB)236 237 assertNotNull("duress slot populated from Codex 'b'", dstStorage.decoy)238 assertEquals("restored duress blob matches the source duress", srcDuress.blob, dstStorage.decoy!!.blob)239 }240}