HiddenSessionStateTest.kt

12.9 KB · raw

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.AtomicReference1314/**15 * M-04: no operation may leave in-memory session state that disagrees with what16 * is on disk, and the sync-key accessors must fail closed whenever the session17 * is not a fully-unlocked real vault.18 */19class 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    // A failed escrow write must not leave the session claiming words are set:63    // the recovery screen would stop offering the confirm step against a slot A64    // that holds no escrow at all.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        // And the retry still works once storage recovers.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    // A recovery unlock installs a root key but no password key — the session is116    // not unlocked yet, so nothing may leave the device under that identity.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        // Finishing the reset unlocks the session and the sync identity returns.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        // Build an authenticated Android recovery escrow whose payload decodes but225        // carries a one-byte root key. The failure must not publish a half-adopted226        // candidate, and the implementation must scrub the rejected decoded bytes.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}