DuressSlotBTest.kt

13.5 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.runBlocking4import kotlinx.serialization.json.JsonPrimitive5import kotlinx.serialization.json.buildJsonObject6import org.junit.Assert.assertEquals7import org.junit.Assert.assertFalse8import org.junit.Assert.assertNotEquals9import org.junit.Assert.assertNotNull10import org.junit.Assert.assertNull11import org.junit.Assert.assertTrue12import org.junit.Test13import java.util.Base641415/** In-memory [VaultStorage] including a decoy (slot B) slot, for the MF-6 duress model. */16private class DuressFakeStorage : VaultStorage {17    var slotA: StoredVault? = null18    var slotB: String? = null19    private var ver: Long = 020    private var settings = VaultSettings()21    override fun loadSlotA() = slotA22    override fun hasVault() = slotA != null23    override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }24    override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }25    var decoy: StoredVault? = null26    override fun loadDecoySlot() = decoy27    override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }28    override fun hasDecoySlot() = decoy != null29    override fun clearDecoySlot() { decoy = null }30    override fun version() = ver31    override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }32    override fun loadSettings() = settings33    override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }34    var history = VaultHistory()35    override fun loadVaultHistory() = history36    override fun writeVaultHistory(h: VaultHistory): Boolean { history = h; return true }37    override fun clearVault() { slotA = null; slotB = null; decoy = null; ver = 0; settings = VaultSettings(); history = VaultHistory() }38}3940/**41 * MF-6: the duress passphrase keys a real second vault (slot B), sealed exactly42 * like slot A. At the lock screen, both slots are tried and the outcome is43 * invisible from outside — no sentinel, no separate hash.44 */45class DuressSlotBTest {46    private val real = "the-real-password-123"47    private val duressPass = "open-the-decoy-456"4849    private fun configuredRepo(): VaultRepository = runBlocking {50        val repo = VaultRepository(DuressFakeStorage())51        repo.createVault(real)52        repo.addEntry(VaultEntry(id = "", title = "Secret", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))53        assertTrue("duress passphrase sealed into slot B", repo.setDuressPassphrase(duressPass))54        repo.lock()55        repo56    }5758    @Test fun real_password_opens_slot_a_only() = runBlocking {59        val repo = configuredRepo()60        assertNotNull("real password opens slot A", repo.tryUnlockSlotA(real))61        assertNull("real password does NOT open the duress slot", repo.tryUnlockDuress(real))62    }6364    @Test fun duress_passphrase_opens_decoy_only() = runBlocking {65        val repo = configuredRepo()66        assertNull("duress passphrase does NOT open slot A", repo.tryUnlockSlotA(duressPass))67        val duress = repo.tryUnlockDuress(duressPass)68        assertNotNull("duress passphrase opens the duress slot", duress)69        assertEquals(UnlockResult.Success, repo.commitDuressUnlock(duress!!))70        assertTrue(repo.isUnlocked.value)71        assertEquals("decoy vault is empty — no real entries leak", 0, repo.getEntries().size)72    }7374    @Test fun sibling_duress_tokens_cannot_replace_the_adopted_decoy_session() = runBlocking {75        val repo = configuredRepo()76        val first = repo.tryUnlockDuress(duressPass)!!77        val sibling = repo.tryUnlockDuress(duressPass)!!78        assertEquals(UnlockResult.Success, repo.commitDuressUnlock(first))79        assertEquals(UnlockResult.Error("stale_unlock"), repo.commitDuressUnlock(sibling))80        assertTrue(repo.isUnlocked.value)81    }8283    @Test fun wrong_password_opens_neither() = runBlocking {84        val repo = configuredRepo()85        assertNull(repo.tryUnlockSlotA("totally-wrong"))86        assertNull(repo.tryUnlockDuress("totally-wrong"))87    }8889    @Test fun duress_passphrase_cannot_equal_real_password() = runBlocking {90        val repo = VaultRepository(DuressFakeStorage())91        repo.createVault(real)92        assertFalse("a duress passphrase matching the real password is rejected", repo.setDuressPassphrase(real))93    }9495    @Test fun clear_duress_replaces_with_fake_of_matching_size() = runBlocking {96        // H-04: presence-indistinguishable — clearDuress no longer removes the97        // duress record, it replaces it with a fake blob the duress passphrase98        // can't open. The at-rest slot pair keeps the same shape.99        val storage = DuressFakeStorage()100        val repo = VaultRepository(storage)101        repo.createVault(real)102        repo.addEntry(VaultEntry(id = "", title = "Real", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))103        assertTrue(repo.setDuressPassphrase(duressPass))104        assertEquals(UnlockResult.Success, repo.unlock(real))105        val slotALen = storage.slotA!!.blob.length106        val decoyBefore = storage.decoy!!107        assertEquals(slotALen, decoyBefore.blob.length)108        assertTrue(repo.clearDuress())109        assertNotNull("duress record still present after clear", storage.decoy)110        // The fake has the same blob length as slot A — forensic size tell closed.111        assertEquals(slotALen, storage.decoy!!.blob.length)112        // …and the duress passphrase no longer opens it.113        repo.lock()114        assertNull(repo.tryUnlockDuress(duressPass))115    }116117    @Test fun `HOLE 5 the duress vault has no sync identity and cannot sync`() = runBlocking {118        val repo = configuredRepo()119120        // Baseline: the real vault does have a sync identity.121        assertNotNull(repo.tryUnlockSlotA(real))122        assertEquals(UnlockResult.Success, repo.unlock(real))123        val realVaultId = repo.syncVaultId()124        assertNotNull("the real vault syncs", realVaultId)125        repo.lock()126127        // The decoy session must expose neither a sync key nor a vault id.128        // A second vault_id would be a duress indicator: a coerced user opening129        // the decoy would make sync visibly fail on their other devices.130        val duress = repo.tryUnlockDuress(duressPass)131        assertNotNull(duress)132        assertEquals(UnlockResult.Success, repo.commitDuressUnlock(duress!!))133        assertTrue(repo.isUnlocked.value)134        assertNull("the duress vault must expose no sync key", repo.syncMasterKey())135        assertNull("the duress vault must expose no vault id", repo.syncVaultId())136137        // Every sync adapter operation therefore fails closed rather than138        // syncing, manifesting, or revealing an entry count.139        val adapter = com.peachpasswords.sync.VaultSyncAdapter(repo, ourDeviceId = "decoy-device")140        var threw = false141        try {142            adapter.computeVaultId()143        } catch (_: Exception) {144            threw = true145        }146        assertTrue("the decoy vault must not produce a vault id for sync", threw)147    }148149    // ---- H-04: duress storage forensics ----------------------------------150151    /**152     * H-04: the duress slot's stored blob must match slot A's stored blob153     * length on disk — both when a real decoy is configured (padded at154     * setDuressPassphrase) and when no decoy is configured (a fake of slot A's155     * size is left behind so the duress record's PRESENCE on disk is156     * independent of duressConfigured).157     */158    @Test fun h04_duress_record_size_matches_slot_a() = runBlocking {159        // Empty vault, no duress configured.160        val storage = DuressFakeStorage()161        val repo = VaultRepository(storage)162        repo.createVault(real)163        val slotALen = storage.slotA!!.blob.length164        assertNotNull("duress record exists even with no duress configured (H-04)", storage.decoy)165        assertEquals("fake duress blob length matches slot A blob length", slotALen, storage.decoy!!.blob.length)166167        // With a real duress configured — blob length is still matched.168        assertTrue(repo.setDuressPassphrase(duressPass))169        assertEquals("real duress blob length matches slot A blob length", slotALen, storage.decoy!!.blob.length)170171        // Slot A grows (entries added): the duress record is RE-PADDED on172        // every persist (the real decoy can't be re-encrypted without its173        // passphrase, so it's left alone — see the setDuressPassphrase174        // ponytail note for the residual tell). A fake duress IS re-padded175        // so it stays indistinguishable from the noise slot.176        repo.addEntry(VaultEntry(id = "", title = "Big", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))177        repo.clearDuress()178        val newSlotALen = storage.slotA!!.blob.length179        assertEquals("fake duress re-padded to slot A after slot A grows", newSlotALen, storage.decoy!!.blob.length)180    }181182    /**183     * H-04: a snapshot must capture the duress slot, not just the noise slot.184     * Without this, restoring from history overwrites slot A (the real vault)185     * while the duress slot stays on whatever the previous state was — a186     * forensic tell across restores.187     */188    @Test fun h04_snapshot_contains_duress() = runBlocking {189        val storage = DuressFakeStorage()190        val repo = VaultRepository(storage)191        repo.createVault(real)192        val initialDuress = storage.decoy193        assertNotNull(initialDuress)194        // The first snapshot is taken on every persist — grab it.195        repo.addEntry(VaultEntry(id = "", title = "T", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))196        val snap = storage.history.snapshots.first()197        assertNotNull("snapshot.vault_d captures the duress slot", snap.vault_d)198        assertEquals("snapshotted duress matches the on-disk duress", initialDuress!!.blob, VaultCodec.decodeSlot(snap.vault_d!!)!!.blob)199        // Restoring it writes the captured duress back to disk (atomic).200        assertTrue(repo.restoreVaultSnapshot(snap.id))201        assertNotNull(storage.decoy)202        assertEquals("restored duress matches the snapshot", VaultCodec.encodeSlot(initialDuress), snap.vault_d)203    }204205    /**206     * H-04: a Codex restore with a browser-shaped "b" (the browser's duress207     * slot, written there because on the browser slot B IS the duress slot)208     * must land in Android's duress slot, not the noise slot. Writing it to209     * the noise slot would mean the duress record disappears on every Codex210     * restore from a paired browser — another forensic tell.211     */212    @Test fun h04_codex_restore_puts_browser_b_into_duress_slot() = runBlocking {213        // Source vault: a real Android duress exists.214        val srcStorage = DuressFakeStorage()215        val src = VaultRepository(srcStorage)216        src.createVault(real)217        src.addEntry(VaultEntry(id = "", title = "Real", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))218        assertTrue(src.setDuressPassphrase(duressPass))219        val srcDuress = srcStorage.decoy!!220221        // Build a browser-shaped Codex: "b" holds the duress slot as a JSON222        // STRING (the browser writes each slot as a string, not an object),223        // matching the browser's A/B naming where B is the duress slot.224        val androidBlob = src.codexVaultBlobBase64()!!225        val wrapper = String(Base64.getUrlDecoder().decode(androidBlob), Charsets.UTF_8)226        // H-04: codex now carries "a" and "b" as siblings — match the first227        // balanced `{…}` after each key, not the wrapper's last brace.228        val slotAJson = balancedBraceAfter(wrapper, "\"a\"")229        val slotDJson = balancedBraceAfter(wrapper, "\"b\"")230        val browserWrapper = buildJsonObject {231            put("a", JsonPrimitive(slotAJson))232            put("b", JsonPrimitive(slotDJson))233        }.toString()234        val browserCodex = Base64.getUrlEncoder().withoutPadding()235            .encodeToString(browserWrapper.toByteArray(Charsets.UTF_8))236237        // Restore into a fresh device that already has a vault.238        val dstStorage: DuressFakeStorage = DuressFakeStorage()239        val dst: VaultRepository = VaultRepository(dstStorage)240        dst.createVault(real)241242        assertEquals(UnlockResult.Success, dst.restoreFromCodex(browserCodex, real))243244        // The duress slot now carries the Codex's "b" — not a fake, not noise.245        assertNotNull("duress slot populated from Codex 'b'", dstStorage.decoy)246        assertEquals("restored duress blob matches the source duress", srcDuress.blob, dstStorage.decoy!!.blob)247        // The noise slot is rewritten to match slot A's size (H-04 size-match248        // invariant) — assert the cross-contamination invariant: noise and249        // duress slots are different.250        assertNotEquals("noise slot is not the duress slot", dstStorage.slotB, dstStorage.decoy?.blob)251    }252253    /**254     * Match the first balanced `{…}` whose opening `{` follows the given key255     * literal. Used after H-04 to extract slot A or B from a wrapper that now256     * carries both slots side by side (regex `\{.*\}` would match through to257     * the wrapper's last brace).258     */259    private fun balancedBraceAfter(wrapper: String, keyLiteral: String): String {260        val openMarker = "${Regex.escape(keyLiteral)}\\s*:\\s*\\{".toRegex().find(wrapper)261            ?: error("key $keyLiteral not found")262        val open = openMarker.range.last263        var depth = 1264        var end = open + 1265        while (end < wrapper.length && depth > 0) {266            when (wrapper[end]) {267                '{' -> depth++268                '}' -> depth--269            }270            if (depth == 0) break271            end++272        }273        return wrapper.substring(open, end + 1)274    }275}