AdoptDuressSlotTest.kt

9.3 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(): Boolean { slotA = null; slotB = null; decoy = null; ver = 0; settings = VaultSettings(); history = VaultHistory(); return true }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 = 1,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    }143144    @Test145    fun `exportDuressSlotForAdoption round trip with installAdoptedDuressSlot`() = runBlocking {146        // Source: create vault with duress configured.147        val src = VaultRepository(MemStorage())148        src.createVault(mainPassword)149        src.addEntry(VaultEntry(id = "", title = "RealEntry", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))150        assertTrue(src.setDuressPassphrase(duressPassword))151152        // Export via the production method (Android -> browser shape).153        val exportedJson = src.exportDuressSlotForAdoption()154        assertNotNull("exportDuressSlotForAdoption returns non-null when duress configured", exportedJson)155156        // Destination: create a fresh vault (simulating adoption).157        val dst = VaultRepository(MemStorage())158        dst.createVault(mainPassword)159160        // Install the exported browser duress slot.161        assertTrue("installAdoptedDuressSlot succeeds with exported JSON",162            dst.installAdoptedDuressSlot(exportedJson!!))163164        // Lock and verify duress unlock works.165        dst.lock()166        val duress = dst.tryUnlockDuress(duressPassword)167        assertNotNull("duress passphrase unlocks the adopted duress slot via round trip", duress)168    }169170    @Test171    fun `exportDuressSlotForAdoption returns null when duress not configured`() = runBlocking {172        val repo = VaultRepository(MemStorage())173        repo.createVault(mainPassword)174        // Duress not configured — decoy slot may exist as noise but duressConfigured is false.175        assertNull("returns null when duress not configured", repo.exportDuressSlotForAdoption())176    }177178    @Test179    fun `exportDuressSlotForAdoption returns null when no vault exists`() = runBlocking {180        val repo = VaultRepository(MemStorage())181        // No vault created.182        assertNull("returns null when no vault exists", repo.exportDuressSlotForAdoption())183    }184185    @Test186    fun `exportDuressSlotForAdoption JSON includes explicit version 1 for browser strict check`() = runBlocking {187        // Cross-platform contract: the browser's hasValidVaultSchema requires188        // `r.version === 1`. kotlinx-serialization's encodeDefaults = false189        // would otherwise omit BrowserSlotA.version (its Kotlin default); the190        // export builds the JSON via JsonObject so the field is always present.191        val src = VaultRepository(MemStorage())192        src.createVault(mainPassword)193        src.addEntry(VaultEntry(id = "", title = "RealEntry", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))194        assertTrue(src.setDuressPassphrase(duressPassword))195196        val exportedJson = src.exportDuressSlotForAdoption()197        assertNotNull("export returns JSON", exportedJson)198        // The exact wire substring the browser parses.199        assertTrue(200            "wire JSON must include \"version\":1 (browser's strict hasValidVaultSchema rejects missing/other)",201            exportedJson!!.contains("\"version\":1"),202        )203        // Decoding it back via BrowserSlotA must succeed.204        val parsed = BrowserSlot.parse(exportedJson)205        assertNotNull("BrowserSlot.parse decodes the exported JSON", parsed)206        assertEquals("decoded version is 1", 1, parsed!!.version)207    }208}