1package com.peachpasswords.vault23import kotlinx.coroutines.runBlocking4import org.junit.Assert.assertEquals5import org.junit.Assert.assertFalse6import org.junit.Assert.assertNotNull7import org.junit.Assert.assertNull8import org.junit.Assert.assertTrue9import org.junit.Test10import java.util.concurrent.CountDownLatch11import java.util.concurrent.TimeUnit12import java.util.concurrent.atomic.AtomicReference13141516171819class HiddenSessionStateTest {2021 private class FaultStorage : VaultStorage {22 var slotA: StoredVault? = null23 var slotB: String? = null24 var failWriteSlotA = false25 private var ver: Long = 026 var settings = VaultSettings()27 var failLoadSettings = false28 var failWriteSettings = false29 override fun loadSlotA() = slotA30 override fun hasVault() = slotA != null31 override fun writeSlotA(slot: StoredVault): Boolean {32 if (failWriteSlotA) return false33 slotA = slot34 return true35 }36 override fun loadSlotB() = slotB37 override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }38 var decoy: StoredVault? = null39 override fun loadDecoySlot() = decoy40 override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }41 override fun hasDecoySlot() = decoy != null42 override fun clearDecoySlot() { decoy = null }43 override fun version() = ver44 override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }45 override fun loadSettings(): VaultSettings {46 if (failLoadSettings) throw IllegalStateException("settings_read_failed")47 return settings48 }49 override fun writeSettings(s: VaultSettings): Boolean {50 if (failWriteSettings) return false51 settings = s52 return true53 }54 override fun clearVault(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); return true }55 }5657 private fun recoveryWordsOf(repo: VaultRepository): List<String> {58 val key = repo.sessionRootKey()!!59 return try { rootKeyToWords(key) } finally { VaultCrypto.zero(key) }60 }6162 63 64 65 @Test fun failed_escrow_write_restores_prior_recovery_flags() = runBlocking {66 val storage = FaultStorage()67 val repo = VaultRepository(storage)68 repo.createVault("pw")69 val words = recoveryWordsOf(repo)70 assertFalse(repo.isRecoveryWordsSet())7172 storage.failWriteSlotA = true73 assertFalse(repo.setRecoveryWords(words))74 assertFalse("flags must roll back when the escrow never landed", repo.isRecoveryWordsSet())75 assertFalse(repo.verifyRecoveryWords(words))76 assertFalse(repo.hasRecoveryEscrow())7778 79 storage.failWriteSlotA = false80 assertTrue(repo.setRecoveryWords(words))81 assertTrue(repo.isRecoveryWordsSet())82 assertTrue(repo.verifyRecoveryWords(words))83 assertTrue(repo.hasRecoveryEscrow())84 }8586 @Test fun sync_accessors_are_null_when_locked() = runBlocking {87 val repo = VaultRepository(FaultStorage())88 repo.createVault("pw")89 repo.addEntry(90 VaultEntry(91 id = "secret",92 title = "Secret",93 type = "login",94 username = "u",95 password = "p",96 createdAt = 0,97 modifiedAt = 0,98 ),99 )100 assertNotNull(repo.syncMasterKey())101 assertNotNull(repo.syncVaultId())102 repo.lock()103 assertNull(repo.syncMasterKey())104 assertNull(repo.syncVaultId())105 assertTrue(repo.getEntries().isEmpty())106 assertNull(repo.getEntry("secret"))107 assertTrue(repo.getDeletedEntries().isEmpty())108 assertTrue(repo.getActiveEntries().isEmpty())109 assertTrue(repo.getDistinctTags().isEmpty())110 assertTrue(repo.getDistinctFieldValues("username").isEmpty())111 assertTrue(repo.getTombstones().isEmpty())112 assertNull(repo.currentVaultKey())113 }114115 116 117 @Test fun sync_accessors_are_null_during_recovery_half_unlock() = runBlocking {118 val storage = FaultStorage()119 val repo = VaultRepository(storage)120 repo.createVault("pw")121 val words = recoveryWordsOf(repo)122 assertTrue(repo.setRecoveryWords(words))123 repo.lock()124125 assertTrue(repo.unlockWithRecoveryWords(words))126 assertFalse(repo.isUnlocked.value)127 assertNull(repo.syncMasterKey())128 assertNull(repo.syncVaultId())129 assertNotNull("the local-only accessor still sees the recovered key", repo.sessionRootKey())130131 132 assertTrue(repo.resetPasswordWithRecoveryWords(words, "new-pw"))133 assertTrue(repo.isUnlocked.value)134 assertNotNull(repo.syncMasterKey())135 assertNotNull(repo.syncVaultId())136 }137138 @Test fun failed_create_retains_no_session_state() = runBlocking {139 val storage = FaultStorage()140 storage.failWriteSlotA = true141 val repo = VaultRepository(storage)142143 assertTrue(repo.createVault("pw") is CreateVaultResult.Error)144 assertFalse(repo.isUnlocked.value)145 assertNull("a failed create must not retain a root key", repo.sessionRootKey())146 assertNull(repo.syncMasterKey())147 assertNull(repo.currentVaultKey())148 }149150 @Test fun locked_settings_are_safe_default_and_cannot_be_mutated() = runBlocking {151 val storage = FaultStorage()152 val repo = VaultRepository(storage)153 assertTrue(repo.createVault("pw") is CreateVaultResult.Success)154 val requested = VaultSettings(accentColor = "blue", autoLockMinutes = 5)155 repo.lock()156157 var failed = false158 try {159 repo.updateSettings(requested)160 } catch (_: IllegalStateException) {161 failed = true162 }163 assertTrue("locked settings writes must fail", failed)164 assertEquals(VaultSettings(), repo.settings.value)165 assertEquals("locked writes must not reach storage", VaultSettings(), storage.settings)166 }167168 @Test fun stale_settings_write_cannot_publish_or_persist_after_lock() = runBlocking {169 val storage = FaultStorage()170 val repo = VaultRepository(storage)171 assertTrue(repo.createVault("pw") is CreateVaultResult.Success)172 val requested = VaultSettings(accentColor = "blue", autoLockMinutes = 5)173 val paused = CountDownLatch(1)174 val release = CountDownLatch(1)175 val failure = AtomicReference<Throwable?>()176 repo.beforeDurableCommit = {177 paused.countDown()178 check(release.await(5, TimeUnit.SECONDS)) { "settings writer did not release" }179 }180 val writer = Thread {181 try {182 runBlocking { repo.updateSettings(requested) }183 } catch (t: Throwable) {184 failure.set(t)185 }186 }187 writer.start()188 assertTrue("settings writer did not reach durable seam", paused.await(5, TimeUnit.SECONDS))189 repo.lock()190 assertEquals(VaultSettings(), repo.settings.value)191 release.countDown()192 writer.join(5_000)193 assertFalse(writer.isAlive)194 assertTrue("stale settings writer must fail closed", failure.get() is IllegalStateException)195 assertEquals("stale settings must not persist", VaultSettings(), storage.settings)196 }197198 @Test fun failed_full_recovery_settings_load_leaves_no_candidate_session() = runBlocking {199 val storage = FaultStorage()200 val repo = VaultRepository(storage)201 assertTrue(repo.createVault("pw") is CreateVaultResult.Success)202 repo.addEntry(VaultEntry(id = "secret", title = "Secret", type = "login", password = "p", createdAt = 0, modifiedAt = 0))203 val key = repo.sessionRootKey()!!204 val words = try { rootKeyToWords(key) } finally { VaultCrypto.zero(key) }205 assertTrue(repo.setRecoveryWords(words))206 repo.lock()207 storage.failLoadSettings = true208209 assertFalse(repo.unlockWithRecoveryWords(words))210 assertFalse(repo.isUnlocked.value)211 assertNull(repo.sessionRootKey())212 assertTrue(repo.getEntries().isEmpty())213 assertFalse(repo.isRecoveryWordsSet())214 assertEquals(VaultSettings(), repo.settings.value)215 }216217 @Test fun invalid_recovery_root_key_leaves_no_candidate_session() = runBlocking {218 val storage = FaultStorage()219 val repo = VaultRepository(storage)220 assertTrue(repo.createVault("pw") is CreateVaultResult.Success)221 val root = repo.sessionRootKey()!!222 val words = try { rootKeyToWords(root) } finally { VaultCrypto.zero(root) }223224 225 226 227 val material = wordsToRootKeyMaterial(words)228 val salt = ByteArray(VaultCrypto.SALT_SIZE) { it.toByte() }229 val recoveryEscrow = try {230 val recoveryKey = deriveRecoveryKey(material, salt)231 val keyBytes = recoveryKey.encoded232 try {233 val escrowPlaintext = VaultCodec.encode(VaultPayload(rootKeyB64 = "AQ"))234 try {235 val sealed = VaultCrypto.encrypt(escrowPlaintext, keyBytes, VaultCrypto.AAD_RECOVERY)236 try {237 RecoveryEscrow(238 salt = java.util.Base64.getEncoder().withoutPadding().encodeToString(salt),239 blob = java.util.Base64.getEncoder().withoutPadding().encodeToString(sealed),240 )241 } finally {242 VaultCrypto.zero(sealed)243 }244 } finally {245 VaultCrypto.zero(escrowPlaintext)246 }247 } finally {248 VaultCrypto.zero(keyBytes)249 }250 } finally {251 VaultCrypto.zero(material)252 }253 storage.slotA = storage.slotA!!.copy(recoveryEscrow = recoveryEscrow, rootKeyEscrow = null)254 repo.lock()255256 assertFalse(repo.unlockWithRecoveryWords(words))257 assertFalse(repo.isUnlocked.value)258 assertNull("invalid root key must not become a hidden session key", repo.sessionRootKey())259 assertTrue(repo.getEntries().isEmpty())260 assertFalse(repo.isRecoveryWordsSet())261 assertEquals(VaultSettings(), repo.settings.value)262 }263264 @Test fun failed_root_only_recovery_settings_load_leaves_no_candidate_session() = runBlocking {265 val storage = FaultStorage()266 val repo = VaultRepository(storage)267 assertTrue(repo.createVault("pw") is CreateVaultResult.Success)268 val key = repo.sessionRootKey()!!269 val words = try { rootKeyToWords(key) } finally { VaultCrypto.zero(key) }270 assertTrue(repo.setRecoveryWords(words))271 storage.slotA = storage.slotA!!.copy(recoveryEscrow = null)272 repo.lock()273 storage.failLoadSettings = true274275 assertFalse(repo.unlockWithRecoveryWords(words))276 assertFalse(repo.isUnlocked.value)277 assertNull(repo.sessionRootKey())278 assertTrue(repo.getEntries().isEmpty())279 assertFalse(repo.isRecoveryWordsSet())280 assertEquals(VaultSettings(), repo.settings.value)281 }282283 @Test fun unlock_with_key_malformed_payload_is_error_not_wrong_password() = runBlocking {284 val storage = FaultStorage()285 val repo = VaultRepository(storage)286 assertTrue(repo.createVault("pw") is CreateVaultResult.Success)287 val slot = storage.slotA!!288 val key = VaultCrypto.deriveKey("pw", java.util.Base64.getDecoder().decode(slot.salt))289 try {290 val malformed = VaultCrypto.encrypt("not-json".toByteArray(), key, VaultCrypto.AAD_SLOT_A)291 storage.slotA = slot.copy(blob = java.util.Base64.getEncoder().withoutPadding().encodeToString(malformed))292 } finally {293 VaultCrypto.zero(key)294 }295 repo.lock()296 val supplied = VaultCrypto.deriveKey("pw", java.util.Base64.getDecoder().decode(storage.slotA!!.salt))297 val result = try { repo.unlockWithKey(supplied) } finally { VaultCrypto.zero(supplied) }298 assertTrue("malformed authenticated payload must be an Error", result is UnlockResult.Error)299 }300}