AdoptDuressSlotTest.kt

6.1 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.runBlocking4import kotlinx.serialization.json.Json5import org.junit.Assert.assertEquals6import org.junit.Assert.assertFalse7import org.junit.Assert.assertNotNull8import org.junit.Assert.assertNull9import org.junit.Assert.assertTrue10import org.junit.Test11import java.util.Base641213/**14 * Adoption duress slot install tests. Proves that a browser-shaped duress slot15 * received through the adoption snapshot installs correctly on Android: the16 * duress passphrase opens it, invalid input is rejected, and an existing decoy17 * slot is preserved on failure.18 */19class AdoptDuressSlotTest {2021    private class MemStorage : VaultStorage {22        var slotA: StoredVault? = null23        var slotB: String? = null24        private var ver: Long = 025        private var settings = VaultSettings()26        override fun loadSlotA() = slotA27        override fun hasVault() = slotA != null28        override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }29        override fun loadSlotB() = slotB30        override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }31        var decoy: StoredVault? = null32        override fun loadDecoySlot() = decoy33        override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }34        override fun hasDecoySlot() = decoy != null35        override fun clearDecoySlot() { decoy = null }36        override fun version() = ver37        override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }38        override fun loadSettings() = settings39        override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }40        var history = VaultHistory()41        override fun loadVaultHistory() = history42        override fun writeVaultHistory(h: VaultHistory): Boolean { history = h; return true }43        override fun clearVaultHistory() { history = VaultHistory() }44        override fun clearVault() { slotA = null; slotB = null; decoy = null; ver = 0; settings = VaultSettings(); history = VaultHistory() }45    }4647    private val mainPassword = "correct-horse-battery-staple"48    private val duressPassword = "open-the-decoy-vault-456"4950    private fun hex(bytes: ByteArray): String =51        bytes.joinToString("") { "%02x".format(it) }5253    /**54     * Convert an Android StoredVault (decoy slot) into a browser-shaped55     * BrowserSlotA JSON string (hex salt/nonce/ciphertext/tag). Simulates what56     * the browser sends as the `duress_slot` field in the adoption snapshot.57     */58    private fun androidSlotToBrowserJson(slot: StoredVault): String {59        val salt = Base64.getDecoder().decode(slot.salt)60        val blob = Base64.getDecoder().decode(slot.blob)61        val nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)62        val ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)63        val ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)64        val tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)65        val browserSlot = BrowserSlotA(66            version = slot.version,67            salt = hex(salt),68            nonce = hex(nonce),69            ciphertext = hex(ct),70            tag = hex(tag),71        )72        return Json.encodeToString(BrowserSlotA.serializer(), browserSlot)73    }7475    @Test76    fun `installAdoptedDuressSlot installs browser duress that tryUnlockDuress opens`() = runBlocking {77        // Source: create vault with duress configured.78        val srcStorage = MemStorage()79        val src = VaultRepository(srcStorage)80        src.createVault(mainPassword)81        src.addEntry(VaultEntry(id = "", title = "RealEntry", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))82        assertTrue(src.setDuressPassphrase(duressPassword))8384        // Extract the duress slot and convert to browser-shaped JSON.85        val srcDuressSlot = srcStorage.decoy!!86        val browserDuressJson = androidSlotToBrowserJson(srcDuressSlot)8788        // Destination: create a fresh vault (simulating adoption).89        val dstStorage = MemStorage()90        val dst = VaultRepository(dstStorage)91        dst.createVault(mainPassword)9293        // Install the browser duress slot.94        assertTrue("installAdoptedDuressSlot succeeds", dst.installAdoptedDuressSlot(browserDuressJson))9596        // Lock and verify duress unlock works.97        dst.lock()98        val duress = dst.tryUnlockDuress(duressPassword)99        assertNotNull("duress passphrase unlocks the adopted duress slot", duress)100    }101102    @Test103    fun `installAdoptedDuressSlot returns false for invalid JSON`() = runBlocking {104        val storage = MemStorage()105        val repo = VaultRepository(storage)106        repo.createVault(mainPassword)107108        assertFalse("invalid JSON returns false", repo.installAdoptedDuressSlot("not json"))109        assertFalse("empty string returns false", repo.installAdoptedDuressSlot(""))110        assertFalse("null-ish string returns false", repo.installAdoptedDuressSlot("null"))111    }112113    @Test114    fun `installAdoptedDuressSlot returns false for non-browser-slot JSON`() = runBlocking {115        val storage = MemStorage()116        val repo = VaultRepository(storage)117        repo.createVault(mainPassword)118119        // Valid JSON but not a browser slot shape.120        assertFalse("non-browser JSON returns false",121            repo.installAdoptedDuressSlot("""{"foo":"bar"}"""))122    }123124    @Test125    fun `installAdoptedDuressSlot preserves existing decoy on failure`() = runBlocking {126        val storage = MemStorage()127        val repo = VaultRepository(storage)128        repo.createVault(mainPassword)129        assertTrue(repo.setDuressPassphrase(duressPassword))130131        // Capture the existing decoy slot.132        val existingDecoy = storage.decoy!!133134        // Attempt to install invalid duress slot.135        assertFalse(repo.installAdoptedDuressSlot("invalid"))136137        // Existing decoy is unchanged.138        assertEquals("existing decoy preserved on failure",139            existingDecoy.blob, storage.decoy!!.blob)140        assertEquals("existing decoy salt preserved",141            existingDecoy.salt, storage.decoy!!.salt)142    }143}