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 failWriteDecoy = false18 var slotA: StoredVault? = null19 var slotB: String? = null20 private var ver: Long = 021 private var settings = VaultSettings()22 override fun loadDecoySlot() = decoy23 override fun loadSlotA() = slotA24 override fun hasVault() = slotA != null25 override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }26 override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }27 var decoy: StoredVault? = null28 override fun writeDecoySlot(slot: StoredVault): Boolean {29 if (failWriteDecoy) return false30 decoy = slot31 return true32 }33 override fun clearDecoySlot() { decoy = null }34 override fun version() = ver35 override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }36 override fun loadSettings() = settings37 override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }38 var history = VaultHistory()39 override fun loadVaultHistory() = history40 override fun writeVaultHistory(h: VaultHistory): Boolean { history = h; return true }41 override fun clearVault(): Boolean { slotA = null; slotB = null; decoy = null; ver = 0; settings = VaultSettings(); history = VaultHistory(); return true }42}43444546474849class DuressSlotBTest {50 private val real = "the-real-password-123"51 private val duressPass = "open-the-decoy-456"5253 private fun configuredRepo(): VaultRepository = runBlocking {54 val repo = VaultRepository(DuressFakeStorage())55 repo.createVault(real)56 repo.addEntry(VaultEntry(id = "", title = "Secret", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))57 assertTrue("duress passphrase sealed into slot B", repo.setDuressPassphrase(duressPass))58 repo.lock()59 repo60 }6162 @Test fun real_password_opens_slot_a_only() = runBlocking {63 val repo = configuredRepo()64 assertNotNull("real password opens slot A", repo.tryUnlockSlotA(real))65 assertNull("real password does NOT open the duress slot", repo.tryUnlockDuress(real))66 }6768 @Test fun duress_passphrase_opens_decoy_only() = runBlocking {69 val repo = configuredRepo()70 assertNull("duress passphrase does NOT open slot A", repo.tryUnlockSlotA(duressPass))71 val duress = repo.tryUnlockDuress(duressPass)72 assertNotNull("duress passphrase opens the duress slot", duress)73 assertEquals(UnlockResult.Success, repo.commitDuressUnlock(duress!!))74 assertTrue(repo.isUnlocked.value)75 assertEquals("decoy vault is empty — no real entries leak", 0, repo.getEntries().size)76 }7778 @Test fun sibling_duress_tokens_cannot_replace_the_adopted_decoy_session() = runBlocking {79 val repo = configuredRepo()80 val first = repo.tryUnlockDuress(duressPass)!!81 val sibling = repo.tryUnlockDuress(duressPass)!!82 assertEquals(UnlockResult.Success, repo.commitDuressUnlock(first))83 assertEquals(UnlockResult.Error("stale_unlock"), repo.commitDuressUnlock(sibling))84 assertTrue(repo.isUnlocked.value)85 }8687 @Test fun wrong_password_opens_neither() = runBlocking {88 val repo = configuredRepo()89 assertNull(repo.tryUnlockSlotA("totally-wrong"))90 assertNull(repo.tryUnlockDuress("totally-wrong"))91 }92 @Test fun legacy_slot_a_decoy_unlocks_and_migrates_to_slot_b_aad() = runBlocking {93 val storage = DuressFakeStorage()94 val repo = VaultRepository(storage)95 repo.createVault(real)96 assertTrue(repo.setDuressPassphrase(duressPass))97 val current = storage.decoy!!98 val key = VaultCrypto.deriveKey(duressPass, Base64.getDecoder().decode(current.salt))99 val plaintext = VaultCrypto.decrypt(Base64.getDecoder().decode(current.blob), key, VaultCrypto.AAD_SLOT_B)100 val legacyBlob = try {101 VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A)102 } finally {103 VaultCrypto.zero(plaintext)104 VaultCrypto.zero(key)105 }106 storage.decoy = current.copy(version = LEGACY_ANDROID_SLOT_VERSION, blob = Base64.getEncoder().encodeToString(legacyBlob))107 assertNotNull(repo.tryUnlockDuress(duressPass))108 assertEquals(CURRENT_ANDROID_SLOT_VERSION, storage.decoy!!.version)109 val migratedKey = VaultCrypto.deriveKey(duressPass, Base64.getDecoder().decode(storage.decoy!!.salt))110 assertNotNull(VaultCrypto.decrypt(Base64.getDecoder().decode(storage.decoy!!.blob), migratedKey, VaultCrypto.AAD_SLOT_B))111 VaultCrypto.zero(migratedKey)112 }113114 @Test fun legacy_migration_write_failure_preserves_legacy_record() = runBlocking {115 val storage = DuressFakeStorage()116 val repo = VaultRepository(storage)117 repo.createVault(real)118 assertTrue(repo.setDuressPassphrase(duressPass))119 val current = storage.decoy!!120 val key = VaultCrypto.deriveKey(duressPass, Base64.getDecoder().decode(current.salt))121 val plaintext = VaultCrypto.decrypt(Base64.getDecoder().decode(current.blob), key, VaultCrypto.AAD_SLOT_B)122 val legacyBlob = try {123 VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A)124 } finally {125 VaultCrypto.zero(plaintext)126 VaultCrypto.zero(key)127 }128 val legacy = current.copy(129 version = LEGACY_ANDROID_SLOT_VERSION,130 blob = Base64.getEncoder().encodeToString(legacyBlob),131 )132 storage.decoy = legacy133 storage.failWriteDecoy = true134 assertNotNull(repo.tryUnlockDuress(duressPass))135 assertEquals(legacy, storage.decoy)136 }137138 @Test fun duress_passphrase_cannot_equal_real_password() = runBlocking {139 val repo = VaultRepository(DuressFakeStorage())140 repo.createVault(real)141 assertFalse("a duress passphrase matching the real password is rejected", repo.setDuressPassphrase(real))142 }143144 @Test fun clear_duress_replaces_with_fake_of_matching_size() = runBlocking {145 146 147 148 val storage = DuressFakeStorage()149 val repo = VaultRepository(storage)150 repo.createVault(real)151 repo.addEntry(VaultEntry(id = "", title = "Real", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))152 assertTrue(repo.setDuressPassphrase(duressPass))153 assertEquals(UnlockResult.Success, repo.unlock(real))154 val slotALen = storage.slotA!!.blob.length155 val decoyBefore = storage.decoy!!156 assertEquals(slotALen, decoyBefore.blob.length)157 assertTrue(repo.clearDuress())158 assertNotNull("duress record still present after clear", storage.decoy)159 160 assertEquals(slotALen, storage.decoy!!.blob.length)161 162 repo.lock()163 assertNull(repo.tryUnlockDuress(duressPass))164 }165166 @Test fun `HOLE 5 the duress vault has no sync identity and cannot sync`() = runBlocking {167 val repo = configuredRepo()168169 170 assertNotNull(repo.tryUnlockSlotA(real))171 assertEquals(UnlockResult.Success, repo.unlock(real))172 val realVaultId = repo.syncVaultId()173 assertNotNull("the real vault syncs", realVaultId)174 repo.lock()175176 177 178 179 val duress = repo.tryUnlockDuress(duressPass)180 assertNotNull(duress)181 assertEquals(UnlockResult.Success, repo.commitDuressUnlock(duress!!))182 assertTrue(repo.isUnlocked.value)183 assertNull("the duress vault must expose no sync key", repo.syncMasterKey())184 assertNull("the duress vault must expose no vault id", repo.syncVaultId())185186 187 188 val adapter = com.peachpasswords.sync.VaultSyncAdapter(repo, ourDeviceId = "decoy-device")189 var threw = false190 try {191 adapter.computeVaultId()192 } catch (_: Exception) {193 threw = true194 }195 assertTrue("the decoy vault must not produce a vault id for sync", threw)196 }197198 199200 201202203204205206207 @Test fun h04_duress_record_size_matches_slot_a() = runBlocking {208 209 val storage = DuressFakeStorage()210 val repo = VaultRepository(storage)211 repo.createVault(real)212 val slotALen = storage.slotA!!.blob.length213 assertNotNull("duress record exists even with no duress configured (H-04)", storage.decoy)214 assertEquals("fake duress blob length matches slot A blob length", slotALen, storage.decoy!!.blob.length)215216 217 assertTrue(repo.setDuressPassphrase(duressPass))218 assertEquals("real duress blob length matches slot A blob length", slotALen, storage.decoy!!.blob.length)219220 221 222 223 224 225 repo.addEntry(VaultEntry(id = "", title = "Big", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))226 repo.clearDuress()227 val newSlotALen = storage.slotA!!.blob.length228 assertEquals("fake duress re-padded to slot A after slot A grows", newSlotALen, storage.decoy!!.blob.length)229 }230231 232233234235236237 @Test fun h04_snapshot_contains_duress() = runBlocking {238 val storage = DuressFakeStorage()239 val repo = VaultRepository(storage)240 repo.createVault(real)241 val initialDuress = storage.decoy242 assertNotNull(initialDuress)243 244 repo.addEntry(VaultEntry(id = "", title = "T", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))245 val snap = storage.history.snapshots.first()246 assertNotNull("snapshot.vault_d captures the duress slot", snap.vault_d)247 assertEquals("snapshotted duress matches the on-disk duress", initialDuress!!.blob, VaultCodec.decodeSlot(snap.vault_d!!)!!.blob)248 249 assertTrue(repo.restoreVaultSnapshot(snap.id))250 assertNotNull(storage.decoy)251 assertEquals("restored duress matches the snapshot", VaultCodec.encodeSlot(initialDuress), snap.vault_d)252 }253254 255256257258259260261 @Test fun h04_codex_restore_puts_browser_b_into_duress_slot() = runBlocking {262 263 val srcStorage = DuressFakeStorage()264 val src = VaultRepository(srcStorage)265 src.createVault(real)266 src.addEntry(VaultEntry(id = "", title = "Real", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))267 assertTrue(src.setDuressPassphrase(duressPass))268 val srcDuress = srcStorage.decoy!!269270 271 272 273 val androidBlob = src.codexVaultBlobBase64()!!274 val wrapper = String(Base64.getUrlDecoder().decode(androidBlob), Charsets.UTF_8)275 276 277 val slotAJson = balancedBraceAfter(wrapper, "\"a\"")278 val slotDJson = balancedBraceAfter(wrapper, "\"b\"")279 val browserWrapper = buildJsonObject {280 put("a", JsonPrimitive(slotAJson))281 put("b", JsonPrimitive(slotDJson))282 }.toString()283 val browserCodex = Base64.getUrlEncoder().withoutPadding()284 .encodeToString(browserWrapper.toByteArray(Charsets.UTF_8))285286 287 val dstStorage: DuressFakeStorage = DuressFakeStorage()288 val dst: VaultRepository = VaultRepository(dstStorage)289 dst.createVault(real)290291 assertEquals(UnlockResult.Success, dst.restoreFromCodex(browserCodex, real))292293 294 assertNotNull("duress slot populated from Codex 'b'", dstStorage.decoy)295 assertEquals("restored duress blob matches the source duress", srcDuress.blob, dstStorage.decoy!!.blob)296 297 298 299 assertNotEquals("noise slot is not the duress slot", dstStorage.slotB, dstStorage.decoy?.blob)300 }301302 303304305306307308 private fun balancedBraceAfter(wrapper: String, keyLiteral: String): String {309 val openMarker = "${Regex.escape(keyLiteral)}\\s*:\\s*\\{".toRegex().find(wrapper)310 ?: error("key $keyLiteral not found")311 val open = openMarker.range.last312 var depth = 1313 var end = open + 1314 while (end < wrapper.length && depth > 0) {315 when (wrapper[end]) {316 '{' -> depth++317 '}' -> depth--318 }319 if (depth == 0) break320 end++321 }322 return wrapper.substring(open, end + 1)323 }324}