package com.peachpasswords.vault import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import java.util.Base64 /** * Adoption duress slot install tests. Proves that a browser-shaped duress slot * received through the adoption snapshot installs correctly on Android: the * duress passphrase opens it, invalid input is rejected, and an existing decoy * slot is preserved on failure. */ class AdoptDuressSlotTest { private class MemStorage : VaultStorage { var slotA: StoredVault? = null var slotB: String? = null private var ver: Long = 0 private var settings = VaultSettings() override fun loadSlotA() = slotA override fun hasVault() = slotA != null override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true } override fun loadSlotB() = slotB override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true } var decoy: StoredVault? = null override fun loadDecoySlot() = decoy override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true } override fun hasDecoySlot() = decoy != null override fun clearDecoySlot() { decoy = null } override fun version() = ver override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver } override fun loadSettings() = settings override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true } var history = VaultHistory() override fun loadVaultHistory() = history override fun writeVaultHistory(h: VaultHistory): Boolean { history = h; return true } override fun clearVaultHistory() { history = VaultHistory() } override fun clearVault() { slotA = null; slotB = null; decoy = null; ver = 0; settings = VaultSettings(); history = VaultHistory() } } private val mainPassword = "correct-horse-battery-staple" private val duressPassword = "open-the-decoy-vault-456" private fun hex(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it) } /** * Convert an Android StoredVault (decoy slot) into a browser-shaped * BrowserSlotA JSON string (hex salt/nonce/ciphertext/tag). Simulates what * the browser sends as the `duress_slot` field in the adoption snapshot. */ private fun androidSlotToBrowserJson(slot: StoredVault): String { val salt = Base64.getDecoder().decode(slot.salt) val blob = Base64.getDecoder().decode(slot.blob) val nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE) val ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size) val ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16) val tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size) val browserSlot = BrowserSlotA( version = slot.version, salt = hex(salt), nonce = hex(nonce), ciphertext = hex(ct), tag = hex(tag), ) return Json.encodeToString(BrowserSlotA.serializer(), browserSlot) } @Test fun `installAdoptedDuressSlot installs browser duress that tryUnlockDuress opens`() = runBlocking { // Source: create vault with duress configured. val srcStorage = MemStorage() val src = VaultRepository(srcStorage) src.createVault(mainPassword) src.addEntry(VaultEntry(id = "", title = "RealEntry", type = "login", password = "pw", createdAt = 0, modifiedAt = 0)) assertTrue(src.setDuressPassphrase(duressPassword)) // Extract the duress slot and convert to browser-shaped JSON. val srcDuressSlot = srcStorage.decoy!! val browserDuressJson = androidSlotToBrowserJson(srcDuressSlot) // Destination: create a fresh vault (simulating adoption). val dstStorage = MemStorage() val dst = VaultRepository(dstStorage) dst.createVault(mainPassword) // Install the browser duress slot. assertTrue("installAdoptedDuressSlot succeeds", dst.installAdoptedDuressSlot(browserDuressJson)) // Lock and verify duress unlock works. dst.lock() val duress = dst.tryUnlockDuress(duressPassword) assertNotNull("duress passphrase unlocks the adopted duress slot", duress) } @Test fun `installAdoptedDuressSlot returns false for invalid JSON`() = runBlocking { val storage = MemStorage() val repo = VaultRepository(storage) repo.createVault(mainPassword) assertFalse("invalid JSON returns false", repo.installAdoptedDuressSlot("not json")) assertFalse("empty string returns false", repo.installAdoptedDuressSlot("")) assertFalse("null-ish string returns false", repo.installAdoptedDuressSlot("null")) } @Test fun `installAdoptedDuressSlot returns false for non-browser-slot JSON`() = runBlocking { val storage = MemStorage() val repo = VaultRepository(storage) repo.createVault(mainPassword) // Valid JSON but not a browser slot shape. assertFalse("non-browser JSON returns false", repo.installAdoptedDuressSlot("""{"foo":"bar"}""")) } @Test fun `installAdoptedDuressSlot preserves existing decoy on failure`() = runBlocking { val storage = MemStorage() val repo = VaultRepository(storage) repo.createVault(mainPassword) assertTrue(repo.setDuressPassphrase(duressPassword)) // Capture the existing decoy slot. val existingDecoy = storage.decoy!! // Attempt to install invalid duress slot. assertFalse(repo.installAdoptedDuressSlot("invalid")) // Existing decoy is unchanged. assertEquals("existing decoy preserved on failure", existingDecoy.blob, storage.decoy!!.blob) assertEquals("existing decoy salt preserved", existingDecoy.salt, storage.decoy!!.salt) } }