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 sibling_duress_tokens_cannot_replace_the_adopted_decoy_session() = runBlocking {75 val repo = configuredRepo()76 val first = repo.tryUnlockDuress(duressPass)!!77 val sibling = repo.tryUnlockDuress(duressPass)!!78 assertEquals(UnlockResult.Success, repo.commitDuressUnlock(first))79 assertEquals(UnlockResult.Error("stale_unlock"), repo.commitDuressUnlock(sibling))80 assertTrue(repo.isUnlocked.value)81 }8283 @Test fun wrong_password_opens_neither() = runBlocking {84 val repo = configuredRepo()85 assertNull(repo.tryUnlockSlotA("totally-wrong"))86 assertNull(repo.tryUnlockDuress("totally-wrong"))87 }8889 @Test fun duress_passphrase_cannot_equal_real_password() = runBlocking {90 val repo = VaultRepository(DuressFakeStorage())91 repo.createVault(real)92 assertFalse("a duress passphrase matching the real password is rejected", repo.setDuressPassphrase(real))93 }9495 @Test fun clear_duress_replaces_with_fake_of_matching_size() = runBlocking {96 97 98 99 val storage = DuressFakeStorage()100 val repo = VaultRepository(storage)101 repo.createVault(real)102 repo.addEntry(VaultEntry(id = "", title = "Real", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))103 assertTrue(repo.setDuressPassphrase(duressPass))104 assertEquals(UnlockResult.Success, repo.unlock(real))105 val slotALen = storage.slotA!!.blob.length106 val decoyBefore = storage.decoy!!107 assertEquals(slotALen, decoyBefore.blob.length)108 assertTrue(repo.clearDuress())109 assertNotNull("duress record still present after clear", storage.decoy)110 111 assertEquals(slotALen, storage.decoy!!.blob.length)112 113 repo.lock()114 assertNull(repo.tryUnlockDuress(duressPass))115 }116117 @Test fun `HOLE 5 the duress vault has no sync identity and cannot sync`() = runBlocking {118 val repo = configuredRepo()119120 121 assertNotNull(repo.tryUnlockSlotA(real))122 assertEquals(UnlockResult.Success, repo.unlock(real))123 val realVaultId = repo.syncVaultId()124 assertNotNull("the real vault syncs", realVaultId)125 repo.lock()126127 128 129 130 val duress = repo.tryUnlockDuress(duressPass)131 assertNotNull(duress)132 assertEquals(UnlockResult.Success, repo.commitDuressUnlock(duress!!))133 assertTrue(repo.isUnlocked.value)134 assertNull("the duress vault must expose no sync key", repo.syncMasterKey())135 assertNull("the duress vault must expose no vault id", repo.syncVaultId())136137 138 139 val adapter = com.peachpasswords.sync.VaultSyncAdapter(repo, ourDeviceId = "decoy-device")140 var threw = false141 try {142 adapter.computeVaultId()143 } catch (_: Exception) {144 threw = true145 }146 assertTrue("the decoy vault must not produce a vault id for sync", threw)147 }148149 150151 152153154155156157158 @Test fun h04_duress_record_size_matches_slot_a() = runBlocking {159 160 val storage = DuressFakeStorage()161 val repo = VaultRepository(storage)162 repo.createVault(real)163 val slotALen = storage.slotA!!.blob.length164 assertNotNull("duress record exists even with no duress configured (H-04)", storage.decoy)165 assertEquals("fake duress blob length matches slot A blob length", slotALen, storage.decoy!!.blob.length)166167 168 assertTrue(repo.setDuressPassphrase(duressPass))169 assertEquals("real duress blob length matches slot A blob length", slotALen, storage.decoy!!.blob.length)170171 172 173 174 175 176 repo.addEntry(VaultEntry(id = "", title = "Big", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))177 repo.clearDuress()178 val newSlotALen = storage.slotA!!.blob.length179 assertEquals("fake duress re-padded to slot A after slot A grows", newSlotALen, storage.decoy!!.blob.length)180 }181182 183184185186187188 @Test fun h04_snapshot_contains_duress() = runBlocking {189 val storage = DuressFakeStorage()190 val repo = VaultRepository(storage)191 repo.createVault(real)192 val initialDuress = storage.decoy193 assertNotNull(initialDuress)194 195 repo.addEntry(VaultEntry(id = "", title = "T", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))196 val snap = storage.history.snapshots.first()197 assertNotNull("snapshot.vault_d captures the duress slot", snap.vault_d)198 assertEquals("snapshotted duress matches the on-disk duress", initialDuress!!.blob, VaultCodec.decodeSlot(snap.vault_d!!)!!.blob)199 200 assertTrue(repo.restoreVaultSnapshot(snap.id))201 assertNotNull(storage.decoy)202 assertEquals("restored duress matches the snapshot", VaultCodec.encodeSlot(initialDuress), snap.vault_d)203 }204205 206207208209210211212 @Test fun h04_codex_restore_puts_browser_b_into_duress_slot() = runBlocking {213 214 val srcStorage = DuressFakeStorage()215 val src = VaultRepository(srcStorage)216 src.createVault(real)217 src.addEntry(VaultEntry(id = "", title = "Real", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))218 assertTrue(src.setDuressPassphrase(duressPass))219 val srcDuress = srcStorage.decoy!!220221 222 223 224 val androidBlob = src.codexVaultBlobBase64()!!225 val wrapper = String(Base64.getUrlDecoder().decode(androidBlob), Charsets.UTF_8)226 227 228 val slotAJson = balancedBraceAfter(wrapper, "\"a\"")229 val slotDJson = balancedBraceAfter(wrapper, "\"b\"")230 val browserWrapper = buildJsonObject {231 put("a", JsonPrimitive(slotAJson))232 put("b", JsonPrimitive(slotDJson))233 }.toString()234 val browserCodex = Base64.getUrlEncoder().withoutPadding()235 .encodeToString(browserWrapper.toByteArray(Charsets.UTF_8))236237 238 val dstStorage: DuressFakeStorage = DuressFakeStorage()239 val dst: VaultRepository = VaultRepository(dstStorage)240 dst.createVault(real)241242 assertEquals(UnlockResult.Success, dst.restoreFromCodex(browserCodex, real))243244 245 assertNotNull("duress slot populated from Codex 'b'", dstStorage.decoy)246 assertEquals("restored duress blob matches the source duress", srcDuress.blob, dstStorage.decoy!!.blob)247 248 249 250 assertNotEquals("noise slot is not the duress slot", dstStorage.slotB, dstStorage.decoy?.blob)251 }252253 254255256257258259 private fun balancedBraceAfter(wrapper: String, keyLiteral: String): String {260 val openMarker = "${Regex.escape(keyLiteral)}\\s*:\\s*\\{".toRegex().find(wrapper)261 ?: error("key $keyLiteral not found")262 val open = openMarker.range.last263 var depth = 1264 var end = open + 1265 while (end < wrapper.length && depth > 0) {266 when (wrapper[end]) {267 '{' -> depth++268 '}' -> depth--269 }270 if (depth == 0) break271 end++272 }273 return wrapper.substring(open, end + 1)274 }275}